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}` — удалить завершённый конкурс.
## Даты и часовые пояса