mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
@@ -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 # Путь к файлу с токенами
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_"))
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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_"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Некорректное число",
|
||||
|
||||
@@ -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()
|
||||
|
||||
118
app/services/nalogo_service.py
Normal file
118
app/services/nalogo_service.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` — удалить завершённый конкурс.
|
||||
|
||||
## Даты и часовые пояса
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ python-multipart==0.0.9
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
nalogo
|
||||
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user