Files
remnawave-bedolaga-telegram…/app/handlers/admin/contests.py
T
2025-12-14 01:38:22 +03:00

550 lines
19 KiB
Python

import logging
import math
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
create_referral_contest,
get_contest_events_count,
get_contest_leaderboard,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
toggle_referral_contest,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_admin_pagination_keyboard,
get_contest_mode_keyboard,
get_referral_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
PAGE_SIZE = 5
def _ensure_timezone(tz_name: str) -> ZoneInfo:
try:
return ZoneInfo(tz_name)
except Exception: # noqa: BLE001
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
return ZoneInfo("UTC")
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
start_local = start_local.astimezone(tz)
end_local = end_local.astimezone(tz)
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
)
period = (
f"{start_local.strftime('%d.%m %H:%M')}"
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
)
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
parts = [
f"{status}",
f"Период: <b>{period}</b>",
f"Дневная сводка: <b>{summary_time}</b>",
]
if contest.prize_text:
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
if contest.last_daily_summary_date:
parts.append(
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
date=contest.last_daily_summary_date.strftime("%d.%m")
)
)
return "\n".join(parts)
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
try:
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
except ValueError:
return None
return dt.replace(tzinfo=tz)
def _parse_time(value: str):
try:
return datetime.strptime(value.strip(), "%H:%M").time()
except ValueError:
return None
@admin_required
@error_handler
async def show_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.message.edit_text(
texts.t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
return
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def show_referral_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def list_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
show_alert=True,
)
return
page = 1
if callback.data.startswith("admin_contests_list_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
total = await get_referral_contests_count(db)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
page = max(1, min(page, total_pages))
offset = (page - 1) * PAGE_SIZE
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
texts = get_texts(db_user.language)
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
if not contests:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for contest in contests:
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
lines.append(_format_contest_summary(contest, texts, contest_tz))
lines.append("")
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
for contest in contests:
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"🔎 {title}",
callback_data=f"admin_contest_view_{contest.id}",
)
]
)
pagination = get_admin_pagination_keyboard(
page,
total_pages,
"admin_contests_list",
back_callback="admin_contests",
language=db_user.language,
)
keyboard_rows.extend(pagination.inline_keyboard)
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_contest_details(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
lines = [
f"🏆 <b>{contest.title}</b>",
_format_contest_summary(contest, texts, tz),
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
]
if contest.description:
lines.append("")
lines.append(contest.description)
if leaderboard:
lines.append("")
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name}{score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest.id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден", show_alert=True)
return
await toggle_referral_contest(db, contest, not contest.is_active)
await show_contest_details(callback, db_user, db)
@admin_required
@error_handler
async def show_leaderboard(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
return
lines = [
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name}{score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def start_contest_creation(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.answer(
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
await state.clear()
await state.set_state(AdminStates.creating_referral_contest_mode)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_MODE_PROMPT",
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
),
reply_markup=get_contest_mode_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def select_contest_mode(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
await state.update_data(contest_type=mode)
await state.set_state(AdminStates.creating_referral_contest_title)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
reply_markup=None,
)
await callback.answer()
@admin_required
@error_handler
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
title = message.text.strip()
texts = get_texts(db_user.language)
await state.update_data(title=title)
await state.set_state(AdminStates.creating_referral_contest_description)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
description = message.text.strip()
if description in {"-", "skip", "пропустить"}:
description = None
await state.update_data(description=description)
await state.set_state(AdminStates.creating_referral_contest_prize)
texts = get_texts(db_user.language)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
prize = message.text.strip()
if prize in {"-", "skip", "пропустить"}:
prize = None
await state.update_data(prize=prize)
await state.set_state(AdminStates.creating_referral_contest_start)
texts = get_texts(db_user.language)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_START",
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
start_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not start_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
await state.update_data(start_at=start_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_end)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_END",
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
end_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not end_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
data = await state.get_data()
start_raw = data.get("start_at")
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
if start_dt and end_dt <= start_dt:
await message.answer(
texts.t(
"ADMIN_CONTEST_END_BEFORE_START",
"Дата окончания должна быть позже даты начала.",
)
)
return
await state.update_data(end_at=end_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_time)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_DAILY_TIME",
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
)
)
@admin_required
@error_handler
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
summary_time = _parse_time(message.text)
texts = get_texts(db_user.language)
if not summary_time:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00")
)
return
data = await state.get_data()
tz = _ensure_timezone(settings.TIMEZONE)
start_at_raw = data.get("start_at")
end_at_raw = data.get("end_at")
if not start_at_raw or not end_at_raw:
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
return
start_at = (
datetime.fromisoformat(start_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
end_at = (
datetime.fromisoformat(end_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
contest_type = data.get("contest_type") or "referral_paid"
contest = await create_referral_contest(
db,
title=data.get("title"),
description=data.get("description"),
prize_text=data.get("prize"),
contest_type=contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=summary_time,
timezone_name=tz.key,
created_by=db_user.id,
)
await state.clear()
await message.answer(
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
reply_markup=get_referral_contest_manage_keyboard(
contest.id,
is_active=contest.is_active,
language=db_user.language,
),
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)