Merge pull request #2164 from BEDOLAGA-DEV/main

ц
This commit is contained in:
Egor
2025-12-17 20:23:29 +03:00
committed by GitHub
25 changed files with 1042 additions and 77 deletions

View File

@@ -175,6 +175,7 @@ DEVICES_SELECTION_ENABLED=true
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -278,6 +279,14 @@ PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем

View File

@@ -158,6 +158,7 @@ class Settings(BaseSettings):
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
CONTESTS_ENABLED: bool = False
CONTESTS_BUTTON_VISIBLE: bool = False
# Для обратной совместимости со старыми конфигами
REFERRAL_CONTESTS_ENABLED: bool = False
@@ -220,6 +221,12 @@ class Settings(BaseSettings):
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
NALOGO_ENABLED: bool = False
NALOGO_INN: Optional[str] = None
NALOGO_PASSWORD: Optional[str] = None
NALOGO_DEVICE_ID: Optional[str] = None
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
# Настройки простой покупки
@@ -993,6 +1000,11 @@ class Settings(BaseSettings):
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def is_nalogo_enabled(self) -> bool:
return (self.NALOGO_ENABLED and
self.NALOGO_INN is not None and
self.NALOGO_PASSWORD is not None)
def is_support_topup_enabled(self) -> bool:
return bool(self.SUPPORT_TOPUP_ENABLED)
@@ -1326,10 +1338,18 @@ class Settings(BaseSettings):
except (ValueError, AttributeError):
return [30, 90, 180]
def get_balance_payment_description(self, amount_kopeks: int) -> str:
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
# Базовое описание
description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
# Если передан user_id, добавляем его
if telegram_user_id is not None:
description += f" (ID {telegram_user_id})"
# Формируем финальную строку по шаблону
return self.PAYMENT_BALANCE_TEMPLATE.format(
service_name=self.PAYMENT_SERVICE_NAME,
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
description=description
)
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:

View File

@@ -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]]:

View File

@@ -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()

View File

@@ -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())

View File

@@ -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",

View File

@@ -1984,7 +1984,7 @@ async def test_payment_provider(
return
amount_kopeks = 10 * 100
description = settings.get_balance_payment_description(amount_kopeks)
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,

View File

@@ -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"Период: <b>{period}</b>",
f"Дневная сводка: <b>{summary_time}</b>",
f"Дневная сводка: <b>{summary_times}</b>",
]
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)

View File

@@ -60,6 +60,10 @@ async def show_daily_contests(
lines.append(f"{status} <b>{tpl.name}</b> (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="<EFBFBD> Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
keyboard_rows.append([types.InlineKeyboardButton(text="<EFBFBD> Запустить все активные конкурсы", 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"<code>{payload_json}</code>",
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_"))

View File

@@ -175,7 +175,7 @@ async def process_yookassa_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={
@@ -321,7 +321,7 @@ async def process_yookassa_sbp_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={

View File

@@ -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_"))

View File

@@ -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"

View File

@@ -432,18 +432,24 @@ 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)
# Добавляем кнопку конкурсов
if settings.CONTESTS_BUTTON_VISIBLE:
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")

View File

@@ -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",

View File

@@ -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": "Некорректное число",

View File

@@ -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"🎲 Стартует игра: <b>{tpl.name}</b>\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()

View File

@@ -0,0 +1,118 @@
import logging
from typing import Optional, Dict, Any
from decimal import Decimal
from nalogo import Client
from nalogo.dto.income import IncomeClient, IncomeType
from app.config import settings
logger = logging.getLogger(__name__)
class NaloGoService:
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
def __init__(self,
inn: Optional[str] = None,
password: Optional[str] = None,
device_id: Optional[str] = None,
storage_path: Optional[str] = None):
inn = inn or getattr(settings, 'NALOGO_INN', None)
password = password or getattr(settings, 'NALOGO_PASSWORD', None)
device_id = device_id or getattr(settings, 'NALOGO_DEVICE_ID', None)
storage_path = storage_path or getattr(settings, 'NALOGO_STORAGE_PATH', './nalogo_tokens.json')
self.configured = False
if not inn or not password:
logger.warning(
"NaloGO INN или PASSWORD не настроены в settings. "
"Функционал чеков будет ОТКЛЮЧЕН.")
else:
try:
self.client = Client(
base_url="https://lknpd.nalog.ru/api",
storage_path=storage_path,
device_id=device_id or "bot-device-123"
)
self.inn = inn
self.password = password
self.configured = True
logger.info(f"NaloGO клиент инициализирован для ИНН: {inn[:5]}...")
except Exception as error:
logger.error(
"Ошибка инициализации NaloGO клиента: %s",
error,
exc_info=True,
)
self.configured = False
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
return False
try:
token = await self.client.create_new_access_token(self.inn, self.password)
await self.client.authenticate(token)
logger.info("Успешная аутентификация в NaloGO")
return True
except Exception as error:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
return False
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Создание чека о доходе.
Args:
name: Название услуги
amount: Сумма в рублях
quantity: Количество
client_info: Информация о клиенте (опционально)
Returns:
UUID чека или None при ошибке
"""
if not self.configured:
logger.warning("NaloGO не настроен, чек не создан")
return None
try:
# Аутентифицируемся, если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
return None
income_api = self.client.income()
# Создаем клиента, если передана информация
income_client = None
if client_info:
income_client = IncomeClient(
contact_phone=client_info.get("phone"),
display_name=client_info.get("name"),
income_type=client_info.get("income_type", IncomeType.FROM_INDIVIDUAL),
inn=client_info.get("inn")
)
result = await income_api.create(
name=name,
amount=Decimal(str(amount)),
quantity=quantity,
client=income_client
)
receipt_uuid = result.get("approvedReceiptUuid")
if receipt_uuid:
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}")
return receipt_uuid
else:
logger.error(f"Ошибка создания чека: {result}")
return None
except Exception as error:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None

View File

@@ -952,6 +952,10 @@ class YooKassaPaymentMixin:
payment.amount_kopeks / 100,
)
# Создаем чек через NaloGO для пополнения баланса
if not is_simple_subscription and hasattr(self, "nalogo_service") and self.nalogo_service:
await self._create_nalogo_receipt(payment)
return True
except Exception as error:
@@ -1002,6 +1006,38 @@ class YooKassaPaymentMixin:
return updated_metadata
async def _create_nalogo_receipt(
self,
payment: "YooKassaPayment",
) -> None:
"""Создание чека через NaloGO для успешного платежа."""
if not hasattr(self, "nalogo_service") or not self.nalogo_service:
logger.debug("NaloGO сервис не инициализирован, чек не создан")
return
try:
amount_rubles = payment.amount_kopeks / 100
receipt_name = "Интернет-сервис - Пополнение баланса"
receipt_uuid = await self.nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
quantity=1
)
if receipt_uuid:
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
else:
logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
except Exception as error:
logger.error(
"Ошибка создания чека NaloGO для платежа %s: %s",
payment.yookassa_payment_id,
error,
exc_info=True,
)
async def process_yookassa_webhook(
self,
db: AsyncSession,

View File

@@ -30,6 +30,7 @@ from app.services.payment import (
)
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
from app.services.nalogo_service import NaloGoService
logger = logging.getLogger(__name__)
@@ -300,6 +301,7 @@ class PaymentService(
PlategaService() if settings.is_platega_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
self.nalogo_service = NaloGoService() if settings.is_nalogo_enabled() else None
mulenpay_name = settings.get_mulenpay_display_name()
logger.debug(

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -19,6 +19,9 @@ python-multipart==0.0.9
# YooKassa SDK
yookassa==3.7.0
# NaloGO для чеков в налоговую
nalogo
# Логирование и мониторинг
structlog==23.2.0