mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-11 14:40:30 +00:00
984 lines
36 KiB
Python
984 lines
36 KiB
Python
import logging
|
||
import math
|
||
from datetime import datetime, timezone, time
|
||
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,
|
||
update_referral_contest,
|
||
delete_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"
|
||
summary_times = contest.daily_summary_times or summary_time
|
||
parts = [
|
||
f"{status}",
|
||
f"Период: <b>{period}</b>",
|
||
f"Дневная сводка: <b>{summary_times}</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
|
||
|
||
|
||
def _parse_times(value: str) -> list[time]:
|
||
times: list[time] = []
|
||
for part in value.split(","):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
parsed = _parse_time(part)
|
||
if parsed:
|
||
times.append(parsed)
|
||
return times
|
||
|
||
|
||
@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,
|
||
can_delete=(
|
||
not contest.is_active
|
||
and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at)
|
||
< datetime.now(timezone.utc)
|
||
),
|
||
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 prompt_edit_summary_times(
|
||
callback: types.CallbackQuery,
|
||
db_user,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
contest_id = int(callback.data.split("_")[-1])
|
||
contest = await get_referral_contest(db, contest_id)
|
||
if not contest:
|
||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||
return
|
||
await state.set_state(AdminStates.editing_referral_contest_summary_times)
|
||
await state.update_data(contest_id=contest_id)
|
||
kb = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=f"admin_contest_view_{contest_id}",
|
||
)
|
||
]
|
||
]
|
||
)
|
||
await callback.message.edit_text(
|
||
texts.t(
|
||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||
"Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).",
|
||
),
|
||
reply_markup=kb,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_edit_summary_times(
|
||
message: types.Message,
|
||
state: FSMContext,
|
||
db_user,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
data = await state.get_data()
|
||
contest_id = data.get("contest_id")
|
||
if not contest_id:
|
||
await message.answer(texts.ERROR)
|
||
await state.clear()
|
||
return
|
||
|
||
times = _parse_times(message.text or "")
|
||
summary_time = times[0] if times else _parse_time(message.text or "")
|
||
if not summary_time:
|
||
await message.answer(
|
||
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
contest = await get_referral_contest(db, int(contest_id))
|
||
if not contest:
|
||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||
await state.clear()
|
||
return
|
||
|
||
await update_referral_contest(
|
||
db,
|
||
contest,
|
||
daily_summary_time=summary_time,
|
||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||
)
|
||
|
||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||
await state.clear()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def delete_contest(
|
||
callback: types.CallbackQuery,
|
||
db_user,
|
||
db: AsyncSession,
|
||
):
|
||
texts = get_texts(db_user.language)
|
||
contest_id = int(callback.data.split("_")[-1])
|
||
contest = await get_referral_contest(db, contest_id)
|
||
if not contest:
|
||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||
return
|
||
|
||
now_utc = datetime.utcnow()
|
||
if contest.is_active or contest.end_at > now_utc:
|
||
await callback.answer(
|
||
texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."),
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
await delete_referral_contest(db, contest)
|
||
await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True)
|
||
await list_contests(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} ({user.telegram_id}) — {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):
|
||
times = _parse_times(message.text or "")
|
||
summary_time = times[0] if times else _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 или 12:00,18: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,
|
||
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
|
||
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,
|
||
),
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_detailed_stats(
|
||
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
|
||
|
||
from app.services.referral_contest_service import referral_contest_service
|
||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||
|
||
# Общее сообщение с основной статистикой
|
||
general_lines = [
|
||
"📈 <b>Статистика конкурса</b>",
|
||
f"🏆 {contest.title}",
|
||
"",
|
||
f"👥 Участников (рефереров): <b>{stats['total_participants']}</b>",
|
||
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
|
||
"",
|
||
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
|
||
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
|
||
"",
|
||
"<b>💰 СУММЫ:</b>",
|
||
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
|
||
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
|
||
]
|
||
|
||
await callback.message.edit_text(
|
||
"\n".join(general_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 show_detailed_stats_page(
|
||
callback: types.CallbackQuery,
|
||
db_user,
|
||
db: AsyncSession,
|
||
contest_id: int = None,
|
||
page: int = 1,
|
||
stats: dict = None,
|
||
):
|
||
if contest_id is None or stats is None:
|
||
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
|
||
parts = callback.data.split("_")
|
||
contest_id = int(parts[5]) # contest_id после page
|
||
page = int(parts[7]) # page после второго page
|
||
|
||
# Получаем stats если не переданы
|
||
from app.services.referral_contest_service import referral_contest_service
|
||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||
|
||
participants = stats['participants']
|
||
total_participants = len(participants)
|
||
PAGE_SIZE = 10
|
||
total_pages = math.ceil(total_participants / PAGE_SIZE)
|
||
|
||
page = max(1, min(page, total_pages))
|
||
offset = (page - 1) * PAGE_SIZE
|
||
page_participants = participants[offset:offset + PAGE_SIZE]
|
||
|
||
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
|
||
for p in page_participants:
|
||
lines.extend([
|
||
f"• <b>{p['full_name']}</b>",
|
||
f" 📨 Приглашено: {p['total_referrals']}",
|
||
f" 💰 Оплатили: {p['paid_referrals']}",
|
||
f" ❌ Не оплатили: {p['unpaid_referrals']}",
|
||
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
|
||
"" # Пустая строка для разделения
|
||
])
|
||
|
||
pagination = get_admin_pagination_keyboard(
|
||
page,
|
||
total_pages,
|
||
f"admin_contest_detailed_stats_page_{contest_id}",
|
||
back_callback=f"admin_contest_view_{contest_id}",
|
||
language=db_user.language,
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
"\n".join(lines),
|
||
reply_markup=pagination,
|
||
)
|
||
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def sync_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 callback.answer("🔄 Синхронизация запущена...", show_alert=False)
|
||
|
||
from app.services.referral_contest_service import referral_contest_service
|
||
stats = await referral_contest_service.sync_contest(db, contest_id)
|
||
|
||
if "error" in stats:
|
||
await callback.message.answer(
|
||
f"❌ Ошибка синхронизации:\n{stats['error']}",
|
||
)
|
||
return
|
||
|
||
# Формируем сообщение о результатах
|
||
# Показываем точные даты которые использовались для фильтрации
|
||
start_str = stats.get('contest_start', contest.start_at.isoformat())
|
||
end_str = stats.get('contest_end', contest.end_at.isoformat())
|
||
|
||
lines = [
|
||
"✅ <b>Синхронизация завершена!</b>",
|
||
"",
|
||
f"📊 <b>Конкурс:</b> {contest.title}",
|
||
f"📅 <b>Период:</b> {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
|
||
f"🔍 <b>Фильтр транзакций:</b>",
|
||
f" <code>{start_str}</code>",
|
||
f" <code>{end_str}</code>",
|
||
"",
|
||
f"📝 Рефералов в периоде: <b>{stats.get('total_events', 0)}</b>",
|
||
f"⚠️ Отфильтровано (вне периода): <b>{stats.get('filtered_out_events', 0)}</b>",
|
||
f"📊 Всего событий в БД: <b>{stats.get('total_all_events', 0)}</b>",
|
||
"",
|
||
f"🔄 Обновлено сумм: <b>{stats.get('updated', 0)}</b>",
|
||
f"⏭ Без изменений: <b>{stats.get('skipped', 0)}</b>",
|
||
"",
|
||
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
|
||
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
|
||
"",
|
||
"<b>💰 СУММЫ:</b>",
|
||
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
|
||
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
|
||
]
|
||
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
|
||
])
|
||
|
||
await callback.message.answer(
|
||
"\n".join(lines),
|
||
parse_mode="HTML",
|
||
reply_markup=back_keyboard,
|
||
)
|
||
|
||
# Обновляем основное сообщение с новой статистикой
|
||
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||
general_lines = [
|
||
f"🏆 <b>{contest.title}</b>",
|
||
f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
|
||
"",
|
||
f"👥 Участников (рефереров): <b>{detailed_stats['total_participants']}</b>",
|
||
f"📨 Приглашено рефералов: <b>{detailed_stats['total_invited']}</b>",
|
||
"",
|
||
f"💳 Рефералов оплатили: <b>{detailed_stats.get('paid_count', 0)}</b>",
|
||
f"❌ Рефералов не оплатили: <b>{detailed_stats.get('unpaid_count', 0)}</b>",
|
||
f"🛒 Покупки подписок: <b>{detailed_stats['total_paid_amount'] // 100} руб.</b>",
|
||
]
|
||
|
||
await callback.message.edit_text(
|
||
"\n".join(general_lines),
|
||
reply_markup=get_referral_contest_manage_keyboard(
|
||
contest_id, is_active=contest.is_active, language=db_user.language
|
||
),
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def debug_contest_transactions(
|
||
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 callback.answer("🔍 Загружаю данные...", show_alert=False)
|
||
|
||
from app.database.crud.referral_contest import debug_contest_transactions as debug_txs
|
||
debug_data = await debug_txs(db, contest_id, limit=10)
|
||
|
||
if "error" in debug_data:
|
||
await callback.message.answer(f"❌ Ошибка: {debug_data['error']}")
|
||
return
|
||
|
||
deposit_total = debug_data.get('deposit_total_kopeks', 0) // 100
|
||
subscription_total = debug_data.get('subscription_total_kopeks', 0) // 100
|
||
|
||
lines = [
|
||
"🔍 <b>Отладка транзакций конкурса</b>",
|
||
"",
|
||
f"📊 <b>Конкурс:</b> {contest.title}",
|
||
f"📅 <b>Период фильтрации:</b>",
|
||
f" Начало: <code>{debug_data.get('contest_start')}</code>",
|
||
f" Конец: <code>{debug_data.get('contest_end')}</code>",
|
||
f"👥 <b>Рефералов в периоде:</b> {debug_data.get('referral_count', 0)}",
|
||
f"⚠️ <b>Отфильтровано (вне периода):</b> {debug_data.get('filtered_out', 0)}",
|
||
f"📊 <b>Всего событий в БД:</b> {debug_data.get('total_all_events', 0)}",
|
||
"",
|
||
"<b>💰 СУММЫ:</b>",
|
||
f" 📥 Пополнения баланса: <b>{deposit_total}</b> руб.",
|
||
f" 🛒 Покупки подписок: <b>{subscription_total}</b> руб.",
|
||
"",
|
||
]
|
||
|
||
# Показываем транзакции В периоде
|
||
txs_in = debug_data.get('transactions_in_period', [])
|
||
if txs_in:
|
||
lines.append(f"✅ <b>Транзакции в периоде</b> (первые {len(txs_in)}):")
|
||
for tx in txs_in[:5]: # Показываем максимум 5
|
||
lines.append(
|
||
f" • {tx['created_at'][:10]} | "
|
||
f"{tx['type']} | "
|
||
f"{tx['amount_kopeks'] // 100}₽ | "
|
||
f"user={tx['user_id']}"
|
||
)
|
||
if len(txs_in) > 5:
|
||
lines.append(f" ... и ещё {len(txs_in) - 5}")
|
||
else:
|
||
lines.append("✅ <b>Транзакций в периоде:</b> 0")
|
||
|
||
lines.append("")
|
||
|
||
# Показываем транзакции ВНЕ периода
|
||
txs_out = debug_data.get('transactions_outside_period', [])
|
||
if txs_out:
|
||
lines.append(f"❌ <b>Транзакции вне периода</b> (первые {len(txs_out)}):")
|
||
for tx in txs_out[:5]:
|
||
lines.append(
|
||
f" • {tx['created_at'][:10]} | "
|
||
f"{tx['type']} | "
|
||
f"{tx['amount_kopeks'] // 100}₽ | "
|
||
f"user={tx['user_id']}"
|
||
)
|
||
if len(txs_out) > 5:
|
||
lines.append(f" ... и ещё {len(txs_out) - 5}")
|
||
else:
|
||
lines.append("❌ <b>Транзакций вне периода:</b> 0")
|
||
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
|
||
])
|
||
|
||
await callback.message.answer(
|
||
"\n".join(lines),
|
||
parse_mode="HTML",
|
||
reply_markup=back_keyboard,
|
||
)
|
||
|
||
|
||
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(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
|
||
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
|
||
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
|
||
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
|
||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
|
||
dp.callback_query.register(sync_contest, F.data.startswith("admin_contest_sync_"))
|
||
dp.callback_query.register(debug_contest_transactions, F.data.startswith("admin_contest_debug_"))
|
||
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)
|
||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|