Files
remnawave-bedolaga-telegram…/app/handlers/admin/daily_contests.py
gy9vin 78d785f83f fix(contests): исправлены критические баги системы конкурсов
- Исправлен вызов get_active_rounds в админ-панели (передавалось 2 параметра вместо 1)
- Обновлены кнопки редактирования призов с prize_days на prize_type/prize_value
- Мигрирован Cabinet API с устаревшего prize_days на новые поля
- Добавлена поддержка нескольких типов призов (дни, баланс, кастом)
- Обновлена документация API конкурсов
2026-01-18 10:15:45 +03:00

556 lines
20 KiB
Python
Raw Blame History

import json
import logging
from datetime import datetime, timedelta
from typing import Dict
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
clear_attempts,
create_round,
get_template_by_id,
list_templates,
update_template_fields,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_daily_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.services.contest_rotation_service import contest_rotation_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
"prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
"prize_value": {"type": str, "label": "значение приза"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
}
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
return await get_template_by_id(db, template_id)
@admin_required
@error_handler
async def show_daily_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=False)
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
if not templates:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")])
keyboard_rows.append([types.InlineKeyboardButton(text="<EFBFBD> Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
keyboard_rows.append([types.InlineKeyboardButton(text="<EFBFBD> Запустить все активные конкурсы", callback_data="admin_daily_start_all")])
for tpl in templates:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"⚙️ {tpl.name}",
callback_data=f"admin_daily_contest_{tpl.id}",
)
]
)
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
await callback.message.edit_text(
"\n".join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
template_id = int(callback.data.split("_")[-1])
except Exception:
await callback.answer("Некорректный id", show_alert=True)
return
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines = [
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
f"Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
f"Длительность раунда: {tpl.cooldown_hours} ч.",
]
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tpl.is_enabled = not tpl.is_enabled
await db.commit()
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def start_round_now(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
if not tpl.is_enabled:
tpl.is_enabled = True
await db.commit()
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def manual_start_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
await show_daily_contest(callback, db_user, db)
return
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_field(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
parts = callback.data.split("_")
template_id = int(parts[3])
field = "_".join(parts[4:]) # поле может содержать подчеркивания
tpl = await _get_template(db, template_id)
if not tpl or field not in EDITABLE_FIELDS:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
meta = EDITABLE_FIELDS[field]
await state.set_state(AdminStates.editing_daily_contest_field)
await state.update_data(template_id=template_id, field=field)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_FIELD_PROMPT",
"Введите новое значение для {label}:",
).format(label=meta.get("label", field)),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_field(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
field = data.get("field")
if not template_id or not field or field not in EDITABLE_FIELDS:
await message.answer(texts.ERROR)
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
meta = EDITABLE_FIELDS[field]
raw = message.text or ""
try:
if meta["type"] is int:
value = int(raw)
if meta.get("min") is not None and value < meta["min"]:
raise ValueError("min")
else:
value = raw.strip()
except Exception:
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
await state.clear()
return
await update_template_fields(db, tpl, **{field: value})
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def edit_payload(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
await state.set_state(AdminStates.editing_daily_contest_value)
await state.update_data(template_id=template_id, field="payload")
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_payload(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
if not template_id:
await message.answer(texts.ERROR)
await state.clear()
return
try:
payload = json.loads(message.text or "{}")
if not isinstance(payload, dict):
raise ValueError
except Exception:
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
await update_template_fields(db, tpl, payload=payload)
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def start_all_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=True)
if not templates:
await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True)
return
started_count = 0
for tpl in templates:
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
started_count += 1
message = f"Запущено конкурсов: {started_count}"
await callback.answer(message, show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def close_all_rounds(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
for rnd in active_rounds:
rnd.status = "finished"
await db.commit()
await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_all_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
total_deleted = 0
for rnd in active_rounds:
deleted = await clear_attempts(db, rnd.id)
total_deleted += deleted
await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template, clear_attempts
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
deleted_count = await clear_attempts(db, round_obj.id)
await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def close_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
round_obj.status = "finished"
await db.commit()
await db.refresh(round_obj)
await callback.answer("Раунд закрыт", show_alert=True)
await show_daily_contest(callback, db_user, db)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily")
dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_"))
dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_"))
dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all")
dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_"))
dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_"))
dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all")
dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts")
dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_"))
dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_"))
dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_"))
dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_"))
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)