diff --git a/app/database/crud/contest.py b/app/database/crud/contest.py index 67c197bb..4f2426d4 100644 --- a/app/database/crud/contest.py +++ b/app/database/crud/contest.py @@ -2,7 +2,7 @@ import logging from datetime import datetime from typing import List, Optional, Sequence, Tuple -from sqlalchemy import and_, desc, func, select +from sqlalchemy import and_, delete, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -47,6 +47,7 @@ async def upsert_template( schedule_times: Optional[str] = None, cooldown_hours: int = 24, payload: Optional[dict] = None, + is_enabled: Optional[bool] = None, ) -> ContestTemplate: template = await get_template_by_slug(db, slug) if not template: @@ -62,7 +63,8 @@ async def upsert_template( template.schedule_times = schedule_times template.cooldown_hours = cooldown_hours template.payload = payload or {} - template.is_enabled = True + if is_enabled is not None: + template.is_enabled = is_enabled await db.commit() await db.refresh(template) return template @@ -137,7 +139,7 @@ async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Op ) .order_by(desc(ContestRound.starts_at)) ) - return result.scalar_one_or_none() + return result.scalars().first() async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound: @@ -187,11 +189,11 @@ async def create_attempt( return attempt -async def count_attempts(db: AsyncSession, round_id: int) -> int: - result = await db.execute( - select(func.count(ContestAttempt.id)).where(ContestAttempt.round_id == round_id) - ) - return int(result.scalar_one()) +async def clear_attempts(db: AsyncSession, round_id: int) -> int: + result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id)) + deleted_count = result.rowcount + await db.commit() + return deleted_count async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]: diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 9155ea03..652e2ee3 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -25,6 +25,7 @@ async def create_referral_contest( start_at: datetime, end_at: datetime, daily_summary_time: time, + daily_summary_times: Optional[str] = None, timezone_name: str, created_by: Optional[int] = None, ) -> ReferralContest: @@ -36,6 +37,7 @@ async def create_referral_contest( start_at=start_at, end_at=end_at, daily_summary_time=daily_summary_time, + daily_summary_times=daily_summary_times, timezone=timezone_name or "UTC", created_by=created_by, ) @@ -250,8 +252,11 @@ async def mark_daily_summary_sent( db: AsyncSession, contest: ReferralContest, summary_date: date, + summary_dt_utc: Optional[datetime] = None, ) -> ReferralContest: contest.last_daily_summary_date = summary_date + if summary_dt_utc: + contest.last_daily_summary_at = summary_dt_utc await db.commit() await db.refresh(contest) return contest @@ -266,3 +271,11 @@ async def mark_final_summary_sent( await db.commit() await db.refresh(contest) return contest + + +async def delete_referral_contest( + db: AsyncSession, + contest: ReferralContest, +) -> None: + await db.delete(contest) + await db.commit() diff --git a/app/database/models.py b/app/database/models.py index 41887159..0bce4c31 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -971,9 +971,11 @@ class ReferralContest(Base): start_at = Column(DateTime, nullable=False) end_at = Column(DateTime, nullable=False) daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0)) + daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM timezone = Column(String(64), nullable=False, default="UTC") is_active = Column(Boolean, nullable=False, default=True) last_daily_summary_date = Column(Date, nullable=True) + last_daily_summary_at = Column(DateTime, nullable=True) final_summary_sent = Column(Boolean, nullable=False, default=False) created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime, default=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 058e3eab..9a7dcb07 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1404,9 +1404,11 @@ async def create_referral_contests_table() -> bool: start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + daily_summary_times VARCHAR(255) NULL, timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', is_active BOOLEAN NOT NULL DEFAULT 1, last_daily_summary_date DATE NULL, + last_daily_summary_at DATETIME NULL, final_summary_sent BOOLEAN NOT NULL DEFAULT 0, created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -1424,9 +1426,11 @@ async def create_referral_contests_table() -> bool: start_at TIMESTAMP NOT NULL, end_at TIMESTAMP NOT NULL, daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + daily_summary_times VARCHAR(255) NULL, timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', is_active BOOLEAN NOT NULL DEFAULT TRUE, last_daily_summary_date DATE NULL, + last_daily_summary_at TIMESTAMP NULL, final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE, created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -1444,9 +1448,11 @@ async def create_referral_contests_table() -> bool: start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + daily_summary_times VARCHAR(255) NULL, timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', is_active BOOLEAN NOT NULL DEFAULT TRUE, last_daily_summary_date DATE NULL, + last_daily_summary_at DATETIME NULL, final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE, created_by INTEGER NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -1538,7 +1544,38 @@ async def create_referral_contest_events_table() -> bool: return True except Exception as error: logger.error(f"Ошибка создания таблицы referral_contest_events: {error}") - return False + return False + + +async def ensure_referral_contest_summary_columns() -> bool: + ok = True + for column in ["daily_summary_times", "last_daily_summary_at"]: + exists = await check_column_exists("referral_contests", column) + if exists: + logger.info("Колонка %s в referral_contests уже существует", column) + continue + try: + async with engine.begin() as conn: + db_type = await get_database_type() + if db_type == "postgresql": + await conn.execute( + text( + f"ALTER TABLE referral_contests ADD COLUMN {column} " + + ("VARCHAR(255)" if column == "daily_summary_times" else "TIMESTAMP") + ) + ) + else: + await conn.execute( + text( + f"ALTER TABLE referral_contests ADD COLUMN {column} " + + ("VARCHAR(255)" if column == "daily_summary_times" else "DATETIME") + ) + ) + logger.info("✅ Колонка %s в referral_contests добавлена", column) + except Exception as error: + ok = False + logger.error("Ошибка добавления %s в referral_contests: %s", column, error) + return ok async def create_contest_templates_table() -> bool: @@ -4401,6 +4438,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось добавить contest_type в referral_contests") + contest_summary_ready = await ensure_referral_contest_summary_columns() + if contest_summary_ready: + logger.info("✅ Колонки daily_summary_times/last_daily_summary_at готовы") + else: + logger.warning("⚠️ Не удалось обновить колонки сводок для referral_contests") + contest_templates_ready = await create_contest_templates_table() if contest_templates_ready: logger.info("✅ Таблица contest_templates готова") @@ -4724,6 +4767,8 @@ async def check_migration_status(): "referral_contests_table": False, "referral_contest_events_table": False, "referral_contest_type_column": False, + "referral_contest_summary_times_column": False, + "referral_contest_last_summary_at_column": False, "contest_templates_table": False, "contest_rounds_table": False, "contest_attempts_table": False, @@ -4753,6 +4798,8 @@ async def check_migration_status(): status["referral_contests_table"] = await check_table_exists('referral_contests') status["referral_contest_events_table"] = await check_table_exists('referral_contest_events') status["referral_contest_type_column"] = await check_column_exists('referral_contests', 'contest_type') + status["referral_contest_summary_times_column"] = await check_column_exists('referral_contests', 'daily_summary_times') + status["referral_contest_last_summary_at_column"] = await check_column_exists('referral_contests', 'last_daily_summary_at') status["contest_templates_table"] = await check_table_exists('contest_templates') status["contest_rounds_table"] = await check_table_exists('contest_rounds') status["contest_attempts_table"] = await check_table_exists('contest_attempts') @@ -4827,6 +4874,8 @@ async def check_migration_status(): "referral_contests_table": "Таблица referral_contests", "referral_contest_events_table": "Таблица referral_contest_events", "referral_contest_type_column": "Колонка contest_type в referral_contests", + "referral_contest_summary_times_column": "Колонка daily_summary_times в referral_contests", + "referral_contest_last_summary_at_column": "Колонка last_daily_summary_at в referral_contests", "contest_templates_table": "Таблица contest_templates", "contest_rounds_table": "Таблица contest_rounds", "contest_attempts_table": "Таблица contest_attempts", diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 8bcf2396..ee5c04cd 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -1,6 +1,6 @@ import logging import math -from datetime import datetime, timezone +from datetime import datetime, timezone, time from zoneinfo import ZoneInfo from aiogram import Dispatcher, F, types @@ -16,6 +16,8 @@ from app.database.crud.referral_contest import ( 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, @@ -57,10 +59,11 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str: ) 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"Период: {period}", - f"Дневная сводка: {summary_time}", + f"Дневная сводка: {summary_times}", ] if contest.prize_text: parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text)) @@ -88,6 +91,18 @@ def _parse_time(value: str): 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( @@ -247,7 +262,14 @@ async def show_contest_details( 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 + 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() @@ -278,6 +300,111 @@ async def toggle_contest( 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( @@ -474,12 +601,13 @@ async def process_end_date(message: types.Message, state: FSMContext, db_user, d @admin_required @error_handler async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession): - summary_time = _parse_time(message.text) + 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") + texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00") ) return @@ -514,6 +642,7 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d 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, ) @@ -537,6 +666,8 @@ def register_handlers(dp: Dispatcher): 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(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"])) @@ -547,3 +678,4 @@ def register_handlers(dp: Dispatcher): 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) diff --git a/app/handlers/admin/daily_contests.py b/app/handlers/admin/daily_contests.py index 58237de1..13731163 100644 --- a/app/handlers/admin/daily_contests.py +++ b/app/handlers/admin/daily_contests.py @@ -60,6 +60,10 @@ async def show_daily_contests( lines.append(f"{status} {tpl.name} (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {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( [ @@ -146,6 +150,11 @@ async def start_round_now( 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) @@ -156,10 +165,59 @@ async def start_round_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( @@ -171,7 +229,7 @@ async def prompt_edit_field( texts = get_texts(db_user.language) parts = callback.data.split("_") template_id = int(parts[3]) - field = parts[4] + field = "_".join(parts[4:]) # поле может содержать подчеркивания tpl = await _get_template(db, template_id) if not tpl or field not in EDITABLE_FIELDS: @@ -181,12 +239,22 @@ async def prompt_edit_field( 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=None, + reply_markup=kb, ) await callback.answer() @@ -229,7 +297,17 @@ async def process_edit_field( return await update_template_fields(db, tpl, **{field: value}) - await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=None) + 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() @@ -251,9 +329,19 @@ async def edit_payload( 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"{payload_json}", - reply_markup=None, + reply_markup=kb, ) await callback.answer() @@ -290,15 +378,171 @@ async def process_payload( return await update_template_fields(db, tpl, payload=payload) - await message.answer(texts.t("ADMIN_UPDATED", "Обновлено")) + 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_")) diff --git a/app/handlers/contests.py b/app/handlers/contests.py index ae8cb143..cfc3a72d 100644 --- a/app/handlers/contests.py +++ b/app/handlers/contests.py @@ -88,9 +88,16 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe return active_rounds = await get_active_rounds(db) - buttons = [] + unique_templates = {} for rnd in active_rounds: + if not rnd.template or not rnd.template.is_enabled: + continue tpl_slug = rnd.template.slug if rnd.template else "" + if tpl_slug not in unique_templates: + unique_templates[tpl_slug] = rnd + + buttons = [] + for tpl_slug, rnd in unique_templates.items(): title = rnd.template.name if rnd.template else tpl_slug buttons.append( [ @@ -122,13 +129,20 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user await _reply_not_eligible(callback, db_user.language) return - try: - _, _, slug, round_id = callback.data.split("_", 3) - round_id = int(round_id) - except Exception: + parts = callback.data.split("_") + if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play": await callback.answer("Некорректные данные", show_alert=True) return + round_id_str = parts[-1] + try: + round_id = int(round_id_str) + except ValueError: + await callback.answer("Некорректные данные", show_alert=True) + return + + slug = "_".join(parts[2:-1]) + # reload round with template async with AsyncSessionLocal() as db2: active_rounds = await get_active_rounds(db2) @@ -136,6 +150,9 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user if not round_obj: await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True) return + if not round_obj.template or not round_obj.template.is_enabled: + await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True) + return attempt = await get_attempt(db2, round_id, db_user.id) if attempt: await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True) @@ -164,6 +181,7 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest texts = get_texts(db_user.language) rows = round_obj.payload.get("rows", 3) cols = round_obj.payload.get("cols", 3) + secret = random.randint(0, rows * cols - 1) keyboard = [] for r in range(rows): row_buttons = [] @@ -172,7 +190,7 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest row_buttons.append( types.InlineKeyboardButton( text="🎛", - callback_data=f"contest_pick_{round_obj.id}_{idx}" + callback_data=f"contest_pick_{round_obj.id}_{idx}_{secret}" ) ) keyboard.append(row_buttons) @@ -187,10 +205,11 @@ async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: Contest async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): texts = get_texts(db_user.language) total = round_obj.payload.get("total", 20) + secret = random.randint(0, total - 1) keyboard = [] row = [] for i in range(total): - row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}")) + row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}_{secret}")) if len(row) == 5: keyboard.append(row) row = [] @@ -207,10 +226,12 @@ async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: Contest async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): texts = get_texts(db_user.language) flags = round_obj.payload.get("flags") or [] + shuffled_flags = flags.copy() + random.shuffle(shuffled_flags) keyboard = [] row = [] - for idx, flag in enumerate(flags): - row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{idx}")) + for flag in shuffled_flags: + row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}")) if len(row) == 5: keyboard.append(row) row = [] @@ -239,10 +260,13 @@ async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: Contes async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext): texts = get_texts(db_user.language) question = round_obj.payload.get("question", "🤔") + emoji_list = question.split() + random.shuffle(emoji_list) + shuffled_question = " ".join(emoji_list) await state.set_state(ContestStates.waiting_for_answer) await state.update_data(contest_round_id=round_obj.id) await callback.message.edit_text( - texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=question), + texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question), reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() @@ -278,10 +302,16 @@ async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: Contest @error_handler async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): texts = get_texts(db_user.language) + parts = callback.data.split("_") + if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick": + await callback.answer("Некорректные данные", show_alert=True) + return + + round_id_str = parts[2] + pick = "_".join(parts[3:]) try: - _, _, round_id_str, pick = callback.data.split("_", 3) round_id = int(round_id_str) - except Exception: + except ValueError: await callback.answer("Некорректные данные", show_alert=True) return @@ -299,15 +329,24 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): return secret_idx = round_obj.payload.get("secret_idx") + correct_flag = "" + if tpl.slug == GAME_SERVER: + flags = round_obj.payload.get("flags") or [] + correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else "" + is_winner = False - if tpl.slug in {GAME_QUEST, GAME_LOCKS, GAME_SERVER}: + if tpl.slug == GAME_SERVER: + is_winner = pick == correct_flag + elif tpl.slug in {GAME_QUEST, GAME_LOCKS}: try: - pick_int = int(pick) - is_winner = pick_int == secret_idx - except Exception: + idx_str, secret_str = pick.split("_", 1) + idx = int(idx_str) + secret = int(secret_str) + is_winner = idx == secret + except ValueError: is_winner = False elif tpl.slug == GAME_BLITZ: - is_winner = True # первый клик получит + is_winner = pick == "blitz" else: is_winner = False @@ -323,7 +362,7 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"], GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"], }.get(tpl.slug, ["Неудача"]) - await callback.answer(random.choice(responses), show_alert=False) + await callback.answer(random.choice(responses), show_alert=True) @auth_required @@ -365,6 +404,24 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, await state.clear() +async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str: + from app.database.crud.subscription import get_subscription_by_user_id + + logger = logging.getLogger(__name__) + + subscription = await get_subscription_by_user_id(db, user_id) + if not subscription: + return "ошибка: подписка не найдена" + + current_time = datetime.utcnow() + subscription.end_date = subscription.end_date + timedelta(days=prize_days) + subscription.updated_at = current_time + await db.commit() + await db.refresh(subscription) + logger.info(f"🎁 Продлена подписка пользователя {user_id} на {prize_days} дней за конкурс") + return f"подписка продлена на {prize_days} дней" + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_contests_menu, F.data == "contests_menu") dp.callback_query.register(play_contest, F.data.startswith("contest_play_")) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 544e013d..e3846883 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -562,6 +562,7 @@ def get_daily_contest_manage_keyboard( [ InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"), InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"), + InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"), ], [ InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"), @@ -578,6 +579,12 @@ def get_daily_contest_manage_keyboard( [ InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"), ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"), + ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"), + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"), ], @@ -588,6 +595,7 @@ def get_referral_contest_manage_keyboard( contest_id: int, *, is_active: bool, + can_delete: bool = False, language: str = "ru", ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -597,27 +605,46 @@ def get_referral_contest_manage_keyboard( else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить") ) - return InlineKeyboardMarkup( - inline_keyboard=[ + rows = [ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"), + callback_data=f"admin_contest_leaderboard_{contest_id}", + ), + InlineKeyboardButton( + text=toggle_text, + callback_data=f"admin_contest_toggle_{contest_id}", + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"), + callback_data=f"admin_contest_edit_times_{contest_id}", + ), + ], + ] + + if can_delete: + rows.append( [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"), - callback_data=f"admin_contest_leaderboard_{contest_id}", - ), - InlineKeyboardButton( - text=toggle_text, - callback_data=f"admin_contest_toggle_{contest_id}", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), - callback_data="admin_contests_list", + text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"), + callback_data=f"admin_contest_delete_{contest_id}", ) - ], + ] + ) + + rows.append( + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), + callback_data="admin_contests_list", + ) ] ) + return InlineKeyboardMarkup(inline_keyboard=rows) + def get_campaign_management_keyboard( campaign_id: int, is_active: bool, language: str = "ru" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index a01189c3..c6d497c4 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -432,18 +432,23 @@ def get_main_menu_keyboard( InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode") ) - # Добавляем кнопку рефералов только если программа включена + # Добавляем кнопку рефералов, только если программа включена if settings.is_referral_program_enabled(): paired_buttons.append( InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals") ) - # Support button is configurable (runtime via service) + # Добавляем кнопку конкурсов + paired_buttons.append( + InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu") + ) + try: from app.services.support_settings_service import SupportSettingsService support_enabled = SupportSettingsService.is_support_menu_enabled() except Exception: support_enabled = settings.SUPPORT_MENU_ENABLED + if support_enabled: paired_buttons.append( InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 02cf3d0f..4390a7de 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -66,7 +66,7 @@ "ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):", "ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:", "ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:", - "ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Use HH:MM (e.g., 12:00).", + "ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Multiple times allowed, comma-separated HH:MM (e.g., 12:00 or 12:00,18:00).", "ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00", "ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00", "ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.", @@ -80,6 +80,11 @@ "ADMIN_CONTEST_LEADERBOARD": "📊 Leaders", "ADMIN_CONTEST_ENABLE": "▶️ Start", "ADMIN_CONTEST_DISABLE": "⏸️ Pause", + "ADMIN_CONTEST_START_MANUAL": "🧪 Manual start", + "ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Daily summaries", + "ADMIN_CONTEST_DELETE": "🗑 Delete", + "ADMIN_CONTEST_DELETE_RESTRICT": "Only finished contests can be deleted.", + "ADMIN_CONTEST_DELETED": "Contest deleted.", "ADMIN_CONTEST_NOT_FOUND": "Contest not found.", "ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.", "ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index e1d2b5cb..757e8220 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -66,7 +66,7 @@ "ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):", "ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:", "ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:", - "ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).", + "ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Можно несколько через запятую, формат ЧЧ:ММ (например, 12:00 или 12:00,18:00).", "ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00", "ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00", "ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.", @@ -80,6 +80,11 @@ "ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры", "ADMIN_CONTEST_ENABLE": "▶️ Запустить", "ADMIN_CONTEST_DISABLE": "⏸️ Остановить", + "ADMIN_CONTEST_START_MANUAL": "🧪 Ручной старт", + "ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Итоги в день", + "ADMIN_CONTEST_DELETE": "🗑 Удалить", + "ADMIN_CONTEST_DELETE_RESTRICT": "Удалять можно только завершённые конкурсы.", + "ADMIN_CONTEST_DELETED": "Конкурс удалён.", "ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.", "ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.", "ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы", @@ -91,7 +96,9 @@ "ADMIN_EDIT_COOLDOWN": "⌛ Длительность", "ADMIN_EDIT_PAYLOAD": "🧩 Payload", "ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:", - "ADMIN_CONTEST_PAYLOAD_PROMPT": "Отправьте JSON payload для игры (словарь настроек):", + "ADMIN_CONTEST_START_NOW": "Запустить раунд сейчас", + "ADMIN_RESET_ATTEMPTS": "🔄 Сбросить попытки", + "ADMIN_CLOSE_ROUND": "❌ Закрыть раунд", "ADMIN_ROUND_STARTED": "Раунд запущен", "ADMIN_UPDATED": "Обновлено", "ADMIN_INVALID_NUMBER": "Некорректное число", diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py index 86411d9e..3984093e 100644 --- a/app/services/contest_rotation_service.py +++ b/app/services/contest_rotation_service.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta, time, timezone from typing import Dict, List, Optional from aiogram import Bot +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,7 +16,7 @@ from app.database.crud.contest import ( upsert_template, ) from app.database.database import AsyncSessionLocal -from app.database.models import ContestTemplate +from app.database.models import ContestTemplate, SubscriptionStatus, User logger = logging.getLogger(__name__) @@ -40,6 +41,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 2, "schedule_times": "10:00,18:00", "payload": {"rows": 3, "cols": 3}, + "is_enabled": False, }, { "slug": GAME_LOCKS, @@ -51,6 +53,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 2, "schedule_times": "09:00,19:00", "payload": {"buttons": 20}, + "is_enabled": False, }, { "slug": GAME_CIPHER, @@ -62,6 +65,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 2, "schedule_times": "12:00,20:00", "payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]}, + "is_enabled": False, }, { "slug": GAME_SERVER, @@ -73,6 +77,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 1, "schedule_times": "15:00", "payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]}, + "is_enabled": False, }, { "slug": GAME_BLITZ, @@ -84,6 +89,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 2, "schedule_times": "11:00,21:00", "payload": {"timeout_seconds": 10}, + "is_enabled": False, }, { "slug": GAME_EMOJI, @@ -95,6 +101,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 1, "schedule_times": "13:00", "payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]}, + "is_enabled": False, }, { "slug": GAME_ANAGRAM, @@ -106,6 +113,7 @@ DEFAULT_TEMPLATES = [ "times_per_day": 1, "schedule_times": "17:00", "payload": {"words": ["SERVER", "XRAY", "VPN"]}, + "is_enabled": False, }, ] @@ -199,6 +207,8 @@ class ContestRotationService: exists = await get_active_round_by_template(db, tpl.id) if exists: continue + # Анонс перед созданием раунда + await self._announce_round_start(tpl, starts_at_local, ends_at_local) payload = self._build_payload_for_template(tpl) round_obj = await create_round( db, @@ -243,5 +253,121 @@ class ContestRotationService: return {"letters": shuffled, "answer": word} return payload + async def _announce_round_start( + self, + tpl: ContestTemplate, + starts_at_local: datetime, + ends_at_local: datetime, + ) -> None: + if not self.bot: + return + + tz = settings.TIMEZONE or "UTC" + starts_txt = starts_at_local.strftime("%d.%m %H:%M") + ends_txt = ends_at_local.strftime("%d.%m %H:%M") + text = ( + f"🎲 Стартует игра: {tpl.name}\n" + f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n" + f"Попыток/польз: {tpl.attempts_per_user}\n\n" + "Участвовать могут только с активной или триальной подпиской." + ) + + await asyncio.gather( + self._send_channel_announce(text), + self._broadcast_to_users(text), + return_exceptions=True, + ) + + async def _send_channel_announce(self, text: str) -> None: + if not self.bot: + return + channel_id_raw = settings.CHANNEL_SUB_ID + if not channel_id_raw: + return + try: + channel_id = int(channel_id_raw) + except Exception: + channel_id = channel_id_raw + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")] + ]) + + try: + await self.bot.send_message( + chat_id=channel_id, + text=text, + disable_web_page_preview=True, + reply_markup=keyboard, + ) + except Exception as exc: # noqa: BLE001 + logger.error("Не удалось отправить анонс в канал %s: %s", channel_id_raw, exc) + + async def _broadcast_to_users(self, text: str) -> None: + """Отправляет анонс всем пользователям с активной/триальной подпиской.""" + if not self.bot: + return + + try: + batch_size = 500 + offset = 0 + sent = failed = 0 + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")] + ]) + + while True: + async with AsyncSessionLocal() as db: + users_batch = await self._load_users_batch(db, offset, batch_size) + if not users_batch: + break + offset += batch_size + + tasks = [] + semaphore = asyncio.Semaphore(15) + + async def _send(u: User): + nonlocal sent, failed + async with semaphore: + try: + await self.bot.send_message( + chat_id=u.telegram_id, + text=text, + disable_web_page_preview=True, + reply_markup=keyboard, + ) + sent += 1 + except Exception: + failed += 1 + await asyncio.sleep(0.02) + + for user in users_batch: + tasks.append(asyncio.create_task(_send(user))) + + await asyncio.gather(*tasks, return_exceptions=True) + + logger.info("Анонс игр: отправлено=%s, ошибок=%s", sent, failed) + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка рассылки анонса игр пользователям: %s", exc) + + async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> List[User]: + from app.database.crud.user import get_users_list + + users = await get_users_list( + db, + offset=offset, + limit=limit, + status=None, + ) + allowed: List[User] = [] + for u in users: + sub = getattr(u, "subscription", None) + if not sub: + continue + if sub.status in {SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value}: + allowed.append(u) + return allowed + contest_rotation_service = ContestRotationService() diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 77a94614..880e3e55 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -109,19 +109,28 @@ class ReferralContestService: start_local = self._to_local(contest.start_at, tz) end_local = self._to_local(contest.end_at, tz) - summary_time = contest.daily_summary_time or time(hour=12, minute=0) - summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz) - if now_local.date() < start_local.date() or now_local.date() > end_local.date(): return - if now_local < summary_dt: - return + summary_times = self._get_summary_times(contest) + for summary_time in summary_times: + summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz) + summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None) - if contest.last_daily_summary_date == now_local.date(): - return + if now_utc < summary_dt_utc: + continue + last_sent = contest.last_daily_summary_at + if last_sent and last_sent >= summary_dt_utc: + continue - await self._send_summary(db, contest, now_utc, now_local.date(), is_final=False) + await self._send_summary( + db, + contest, + now_utc, + now_local.date(), + is_final=False, + summary_dt_utc=summary_dt_utc, + ) async def _maybe_send_final_summary( self, @@ -134,7 +143,8 @@ class ReferralContestService: tz = self._get_timezone(contest) end_local = self._to_local(contest.end_at, tz) - summary_time = contest.daily_summary_time or time(hour=12, minute=0) + summary_times = self._get_summary_times(contest) + summary_time = summary_times[-1] if summary_times else time(hour=12, minute=0) summary_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz) summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None) @@ -154,6 +164,7 @@ class ReferralContestService: target_date: date, *, is_final: bool, + summary_dt_utc: Optional[datetime] = None, ) -> None: tz = self._get_timezone(contest) day_start_local = datetime.combine(target_date, time.min, tzinfo=tz) @@ -194,7 +205,7 @@ class ReferralContestService: if is_final: await mark_final_summary_sent(db, contest) else: - await mark_daily_summary_sent(db, contest, target_date) + await mark_daily_summary_sent(db, contest, target_date, summary_dt_utc) async def _notify_participants( self, @@ -387,6 +398,28 @@ class ReferralContestService: logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) return ZoneInfo("UTC") + def _parse_times(self, times_str: Optional[str]) -> list[time]: + if not times_str: + return [] + parsed: list[time] = [] + for part in times_str.split(","): + part = part.strip() + if not part: + continue + try: + parsed.append(datetime.strptime(part, "%H:%M").time()) + except Exception: + continue + return parsed + + def _get_summary_times(self, contest: ReferralContest) -> list[time]: + times = self._parse_times(contest.daily_summary_times) + if not times and contest.daily_summary_time: + times.append(contest.daily_summary_time) + if not times: + times.append(time(hour=12, minute=0)) + return sorted(times) + def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime: base = dt_value if dt_value.tzinfo is None: diff --git a/app/states.py b/app/states.py index 84950fa0..795a6d67 100644 --- a/app/states.py +++ b/app/states.py @@ -98,6 +98,7 @@ class AdminStates(StatesGroup): creating_referral_contest_start = State() creating_referral_contest_end = State() creating_referral_contest_time = State() + editing_referral_contest_summary_times = State() editing_daily_contest_field = State() editing_daily_contest_value = State() diff --git a/app/webapi/routes/contests.py b/app/webapi/routes/contests.py index eadf7a91..efdd50eb 100644 --- a/app/webapi/routes/contests.py +++ b/app/webapi/routes/contests.py @@ -1,10 +1,10 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, time from typing import Any, Dict, List, Optional from zoneinfo import ZoneInfo -from fastapi import APIRouter, Depends, HTTPException, Query, Security, status +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status, Response from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased, selectinload @@ -28,6 +28,7 @@ from app.database.crud.referral_contest import ( list_referral_contests, toggle_referral_contest, update_referral_contest, + delete_referral_contest, ) from app.database.models import ( ContestAttempt, @@ -137,9 +138,11 @@ def _serialize_referral_contest(contest: ReferralContest) -> ReferralContestResp start_at=contest.start_at, end_at=contest.end_at, daily_summary_time=contest.daily_summary_time, + daily_summary_times=contest.daily_summary_times, timezone=contest.timezone, is_active=contest.is_active, last_daily_summary_date=contest.last_daily_summary_date, + last_daily_summary_at=contest.last_daily_summary_at, final_summary_sent=contest.final_summary_sent, created_by=contest.created_by, created_at=contest.created_at, @@ -147,6 +150,28 @@ def _serialize_referral_contest(contest: ReferralContest) -> ReferralContestResp ) +def _parse_times_str(times_str: Optional[str]) -> List[time]: + if not times_str: + return [] + parsed: List[time] = [] + for part in times_str.split(","): + part = part.strip() + if not part: + continue + try: + parsed.append(datetime.strptime(part, "%H:%M").time()) + except Exception: + continue + return parsed + + +def _primary_time(times_str: Optional[str], fallback: Optional[time]) -> time: + parsed = _parse_times_str(times_str) + if parsed: + return parsed[0] + return fallback or time(hour=12) + + def _serialize_leaderboard_item(row) -> ReferralContestLeaderboardItem: user, referrals_count, total_amount = row total_amount_kopeks = int(total_amount or 0) @@ -261,6 +286,9 @@ async def start_round_now( if not tpl: raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + if not tpl.is_enabled: + tpl = await update_template_fields(db, tpl, is_enabled=True) + existing = await get_active_round_by_template(db, tpl.id) if existing and not payload.force: raise HTTPException( @@ -290,6 +318,11 @@ async def start_round_now( payload=round_payload, ) round_obj.template = tpl + await contest_rotation_service._announce_round_start( # type: ignore[attr-defined] + tpl, + starts_at, + ends_at, + ) return _serialize_round(round_obj) @@ -474,6 +507,8 @@ async def create_referral( if end_at <= start_at: raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at") + summary_time = _primary_time(payload.daily_summary_times, payload.daily_summary_time) + contest = await create_referral_contest( db, title=payload.title, @@ -482,7 +517,8 @@ async def create_referral( contest_type=payload.contest_type, start_at=start_at, end_at=end_at, - daily_summary_time=payload.daily_summary_time, + daily_summary_time=summary_time, + daily_summary_times=payload.daily_summary_times, timezone_name=payload.timezone, created_by=payload.created_by, ) @@ -540,6 +576,11 @@ async def update_referral( fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone) if "end_at" in fields: fields["end_at"] = _to_utc_naive(fields["end_at"], fields.get("timezone") or contest.timezone) + if "daily_summary_times" in fields: + fields["daily_summary_time"] = _primary_time(fields["daily_summary_times"], fields.get("daily_summary_time") or contest.daily_summary_time) + elif "daily_summary_time" in fields: + # ensure type is time (pydantic provides time) + pass new_start = fields.get("start_at", contest.start_at) new_end = fields.get("end_at", contest.end_at) @@ -570,6 +611,29 @@ async def toggle_referral( return _serialize_referral_contest(contest) +@router.delete( + "/referral/{contest_id}", + status_code=status.HTTP_200_OK, + tags=["contests"], +) +async def delete_referral( + contest_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Dict[str, str]: + contest = await get_referral_contest(db, contest_id) + if not contest: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + now_utc = datetime.utcnow() + if contest.is_active or contest.end_at > now_utc: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Можно удалять только завершённые конкурсы", + ) + await delete_referral_contest(db, contest) + return {"status": "deleted"} + + @router.get( "/referral/{contest_id}/events", response_model=ReferralContestEventListResponse, diff --git a/app/webapi/schemas/contests.py b/app/webapi/schemas/contests.py index cbe6997d..9713ed35 100644 --- a/app/webapi/schemas/contests.py +++ b/app/webapi/schemas/contests.py @@ -103,9 +103,11 @@ class ReferralContestResponse(BaseModel): start_at: datetime end_at: datetime daily_summary_time: time + daily_summary_times: Optional[str] = None timezone: str is_active: bool last_daily_summary_date: Optional[date] = None + last_daily_summary_at: Optional[datetime] = None final_summary_sent: bool created_by: Optional[int] = None created_at: datetime @@ -127,6 +129,9 @@ class ReferralContestCreateRequest(BaseModel): start_at: datetime end_at: datetime daily_summary_time: time = Field(default=time(hour=12)) + daily_summary_times: Optional[str] = Field( + default=None, description="Список времён ЧЧ:ММ через запятую (например, 12:00,18:00)" + ) timezone: str = Field(default="UTC") is_active: bool = True created_by: Optional[int] = None @@ -140,6 +145,9 @@ class ReferralContestUpdateRequest(BaseModel): start_at: Optional[datetime] = None end_at: Optional[datetime] = None daily_summary_time: Optional[time] = None + daily_summary_times: Optional[str] = Field( + default=None, description="Список времён ЧЧ:ММ через запятую" + ) timezone: Optional[str] = None is_active: Optional[bool] = None final_summary_sent: Optional[bool] = None diff --git a/docs/contests-api.md b/docs/contests-api.md index 9d635bed..97ab3624 100644 --- a/docs/contests-api.md +++ b/docs/contests-api.md @@ -41,9 +41,10 @@ } ``` - `GET /contests/referral/{id}` — детали + `total_events`, `leaderboard`. -- `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`). +- `PATCH /contests/referral/{id}` — частичное обновление (те же поля + `final_summary_sent`, `is_active`, `daily_summary_times` с несколькими временами через запятую). - `POST /contests/referral/{id}/toggle?is_active=true|false` — быстро включить/остановить. - `GET /contests/referral/{id}/events?limit&offset` — события (referrer/referral, тип, суммы). +- `DELETE /contests/referral/{id}` — удалить завершённый конкурс. ## Даты и часовые пояса