86dd18fbe7
Рефакторинг архитектуры ежедневных конкурсов: - Создан модуль app/services/contests/ с новой архитектурой: - enums.py: GameType, RoundStatus, PrizeType enum классы - games.py: паттерн Стратегия для 7 типов игр - attempt_service.py: ContestAttemptService для атомарных операций - Упрощён handlers/contests.py: - Удалены отдельные _render_* функции (заменены на стратегии) - Логика обработки попыток вынесена в ContestAttemptService - Уменьшено с 523 до 342 строк (-35%) - Обновлён contest_rotation_service.py: - Заменена if-elif цепочка на get_game_strategy().build_payload() - Используются enum классы вместо магических строк - Исправлен handlers/admin/daily_contests.py: - prize_days → prize_type/prize_value (соответствие модели БД) - Обновлены EDITABLE_FIELDS и отображение приза 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
556 lines
20 KiB
Python
556 lines
20 KiB
Python
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="� Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
|
|
keyboard_rows.append([types.InlineKeyboardButton(text="� Запустить все активные конкурсы", 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_rounds
|
|
exists = await get_active_rounds(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)
|