mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Конкурсы
This commit is contained in:
@@ -173,6 +173,8 @@ PRICE_PER_DEVICE=10000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -181,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
@@ -47,6 +47,8 @@ from app.handlers.admin import (
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
contests as admin_contests,
|
||||
daily_contests as admin_daily_contests,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
@@ -63,6 +65,7 @@ from app.handlers.admin import (
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
)
|
||||
from app.handlers import contests as user_contests
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
@@ -162,6 +165,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_contests.register_handlers(dp)
|
||||
admin_daily_contests.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
@@ -182,6 +187,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
@@ -156,6 +156,11 @@ class Settings(BaseSettings):
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
|
||||
CONTESTS_ENABLED: bool = False
|
||||
# Для обратной совместимости со старыми конфигами
|
||||
REFERRAL_CONTESTS_ENABLED: bool = False
|
||||
|
||||
BLACKLIST_CHECK_ENABLED: bool = False
|
||||
BLACKLIST_GITHUB_URL: Optional[str] = None
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
@@ -1190,6 +1195,16 @@ class Settings(BaseSettings):
|
||||
return False
|
||||
return self.HIDE_SUBSCRIPTION_LINK
|
||||
|
||||
def is_contests_enabled(self) -> bool:
|
||||
if getattr(self, "CONTESTS_ENABLED", False):
|
||||
return True
|
||||
# legacy fallback
|
||||
return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False))
|
||||
|
||||
def is_referral_contests_enabled(self) -> bool:
|
||||
# kept for backward compatibility
|
||||
return self.is_contests_enabled()
|
||||
|
||||
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
|
||||
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
|
||||
return template or None
|
||||
|
||||
208
app/database/crud/contest.py
Normal file
208
app/database/crud/contest.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]:
|
||||
query = select(ContestTemplate).order_by(ContestTemplate.id)
|
||||
if enabled_only:
|
||||
query = query.where(ContestTemplate.is_enabled.is_(True))
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_template(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_days: int = 1,
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
schedule_times: Optional[str] = None,
|
||||
cooldown_hours: int = 24,
|
||||
payload: Optional[dict] = None,
|
||||
) -> ContestTemplate:
|
||||
template = await get_template_by_slug(db, slug)
|
||||
if not template:
|
||||
template = ContestTemplate(slug=slug)
|
||||
db.add(template)
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_days = prize_days
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
template.schedule_times = schedule_times
|
||||
template.cooldown_hours = cooldown_hours
|
||||
template.payload = payload or {}
|
||||
template.is_enabled = True
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
async def update_template_fields(
|
||||
db: AsyncSession,
|
||||
template: ContestTemplate,
|
||||
**fields: object,
|
||||
) -> ContestTemplate:
|
||||
for key, value in fields.items():
|
||||
if hasattr(template, key):
|
||||
setattr(template, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
# Rounds
|
||||
async def create_round(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
template: ContestTemplate,
|
||||
starts_at: datetime,
|
||||
ends_at: datetime,
|
||||
payload: dict,
|
||||
) -> ContestRound:
|
||||
round_obj = ContestRound(
|
||||
template_id=template.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status="active",
|
||||
payload=payload,
|
||||
max_winners=template.max_winners,
|
||||
attempts_per_user=template.attempts_per_user,
|
||||
)
|
||||
db.add(round_obj)
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(ContestRound.starts_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.template_id == template_id,
|
||||
ContestRound.status == "active",
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(desc(ContestRound.starts_at))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.status = "finished"
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
# Attempts
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
|
||||
result = await db.execute(
|
||||
select(ContestAttempt).where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_attempt(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
answer: Optional[str],
|
||||
is_winner: bool,
|
||||
) -> ContestAttempt:
|
||||
attempt = ContestAttempt(
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=answer,
|
||||
is_winner=is_winner,
|
||||
)
|
||||
db.add(attempt)
|
||||
await db.commit()
|
||||
await db.refresh(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 list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
|
||||
result = await db.execute(
|
||||
select(User, ContestAttempt)
|
||||
.join(ContestAttempt, ContestAttempt.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.is_winner.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.all()
|
||||
268
app/database/crud/referral_contest.py
Normal file
268
app/database/crud/referral_contest.py
Normal file
@@ -0,0 +1,268 @@
|
||||
import logging
|
||||
from datetime import datetime, date, time, timezone
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_contest(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: Optional[str],
|
||||
prize_text: Optional[str],
|
||||
contest_type: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
daily_summary_time: time,
|
||||
timezone_name: str,
|
||||
created_by: Optional[int] = None,
|
||||
) -> ReferralContest:
|
||||
contest = ReferralContest(
|
||||
title=title,
|
||||
description=description,
|
||||
prize_text=prize_text,
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=daily_summary_time,
|
||||
timezone=timezone_name or "UTC",
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(contest)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def list_referral_contests(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
contest_type: Optional[str] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = (
|
||||
select(ReferralContest)
|
||||
.options(selectinload(ReferralContest.creator))
|
||||
.order_by(desc(ReferralContest.start_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int:
|
||||
query = select(func.count(ReferralContest.id))
|
||||
if contest_type:
|
||||
query = query.where(ReferralContest.contest_type == contest_type)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest)
|
||||
.options(
|
||||
selectinload(ReferralContest.creator),
|
||||
selectinload(ReferralContest.events),
|
||||
)
|
||||
.where(ReferralContest.id == contest_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
**fields: object,
|
||||
) -> ReferralContest:
|
||||
for key, value in fields.items():
|
||||
if hasattr(contest, key):
|
||||
setattr(contest, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def toggle_referral_contest(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
is_active: bool,
|
||||
) -> ReferralContest:
|
||||
contest.is_active = is_active
|
||||
return await update_referral_contest(db, contest)
|
||||
|
||||
|
||||
async def get_contests_for_events(
|
||||
db: AsyncSession,
|
||||
now_utc: datetime,
|
||||
*,
|
||||
contest_types: Optional[List[str]] = None,
|
||||
) -> List[ReferralContest]:
|
||||
query = select(ReferralContest).where(
|
||||
and_(
|
||||
ReferralContest.is_active.is_(True),
|
||||
ReferralContest.start_at <= now_utc,
|
||||
ReferralContest.end_at >= now_utc,
|
||||
)
|
||||
)
|
||||
if contest_types:
|
||||
query = query.where(ReferralContest.contest_type.in_(contest_types))
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]:
|
||||
result = await db.execute(
|
||||
select(ReferralContest).where(ReferralContest.is_active.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def add_contest_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
event_type: str = "subscription_purchase",
|
||||
) -> Optional[ReferralContestEvent]:
|
||||
existing = await db.execute(
|
||||
select(ReferralContestEvent).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referral_id == referral_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
event = ReferralContestEvent(
|
||||
contest_id=contest_id,
|
||||
referrer_id=referrer_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
async def get_contest_leaderboard(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> Sequence[Tuple[User, int, int]]:
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
func.count(ReferralContestEvent.id).label("referral_count"),
|
||||
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
|
||||
)
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc("referral_count"), desc("total_amount"), User.id)
|
||||
)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_contest_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[Tuple[User, int]]:
|
||||
result = await db.execute(
|
||||
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.group_by(User.id)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_referrer_score(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referrer_id == referrer_id,
|
||||
)
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_contest_events_count(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
query = select(func.count(ReferralContestEvent.id)).where(
|
||||
ReferralContestEvent.contest_id == contest_id
|
||||
)
|
||||
if start:
|
||||
query = query.where(ReferralContestEvent.occurred_at >= start)
|
||||
if end:
|
||||
query = query.where(ReferralContestEvent.occurred_at < end)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def mark_daily_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
summary_date: date,
|
||||
) -> ReferralContest:
|
||||
contest.last_daily_summary_date = summary_date
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
|
||||
|
||||
async def mark_final_summary_sent(
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
) -> ReferralContest:
|
||||
contest.final_summary_sent = True
|
||||
contest.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(contest)
|
||||
return contest
|
||||
@@ -50,6 +50,21 @@ async def create_transaction(
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_subscription_payment(
|
||||
db,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось записать событие конкурса для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, time, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Date,
|
||||
Time,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
Table,
|
||||
SmallInteger,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -957,6 +960,130 @@ class ReferralEarning(Base):
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class ReferralContest(Base):
|
||||
__tablename__ = "referral_contests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_text = Column(Text, nullable=True)
|
||||
contest_type = Column(String(50), nullable=False, default="referral_paid")
|
||||
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))
|
||||
timezone = Column(String(64), nullable=False, default="UTC")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
last_daily_summary_date = Column(Date, 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())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", backref="created_referral_contests")
|
||||
events = relationship(
|
||||
"ReferralContestEvent",
|
||||
back_populates="contest",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ReferralContest id={self.id} title='{self.title}'>"
|
||||
|
||||
|
||||
class ReferralContestEvent(Base):
|
||||
__tablename__ = "referral_contest_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"contest_id",
|
||||
"referral_id",
|
||||
name="uq_referral_contest_referral",
|
||||
),
|
||||
Index("idx_referral_contest_referrer", "contest_id", "referrer_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False)
|
||||
referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
event_type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||
|
||||
contest = relationship("ReferralContest", back_populates="events")
|
||||
referrer = relationship("User", foreign_keys=[referrer_id])
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestEvent contest={self.contest_id} "
|
||||
f"referrer={self.referrer_id} referral={self.referral_id}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = "contest_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_days = Column(Integer, nullable=False, default=1)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
times_per_day = Column(Integer, nullable=False, default=1)
|
||||
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
|
||||
cooldown_hours = Column(Integer, nullable=False, default=24)
|
||||
payload = Column(JSON, nullable=True)
|
||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
rounds = relationship("ContestRound", back_populates="template")
|
||||
|
||||
|
||||
class ContestRound(Base):
|
||||
__tablename__ = "contest_rounds"
|
||||
__table_args__ = (
|
||||
Index("idx_contest_round_status", "status"),
|
||||
Index("idx_contest_round_template", "template_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False)
|
||||
starts_at = Column(DateTime, nullable=False)
|
||||
ends_at = Column(DateTime, nullable=False)
|
||||
status = Column(String(20), nullable=False, default="active") # active, finished
|
||||
payload = Column(JSON, nullable=True)
|
||||
winners_count = Column(Integer, nullable=False, default=0)
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
message_id = Column(BigInteger, nullable=True)
|
||||
chat_id = Column(BigInteger, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
template = relationship("ContestTemplate", back_populates="rounds")
|
||||
attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ContestAttempt(Base):
|
||||
__tablename__ = "contest_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"),
|
||||
Index("idx_contest_attempt_round", "round_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
answer = Column(Text, nullable=True)
|
||||
is_winner = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
round = relationship("ContestRound", back_populates="attempts")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
|
||||
@@ -1383,6 +1383,427 @@ async def create_discount_offers_table():
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contests_table() -> bool:
|
||||
table_exists = await check_table_exists("referral_contests")
|
||||
if table_exists:
|
||||
logger.info("Таблица referral_contests уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
last_daily_summary_date DATE 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,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at TIMESTAMP NOT NULL,
|
||||
end_at TIMESTAMP NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_daily_summary_date DATE 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,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contests (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
prize_text TEXT NULL,
|
||||
contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid',
|
||||
start_at DATETIME NOT NULL,
|
||||
end_at DATETIME NOT NULL,
|
||||
daily_summary_time TIME NOT NULL DEFAULT '12:00:00',
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_daily_summary_date DATE NULL,
|
||||
final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_referral_contest_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица referral_contests создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы referral_contests: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contest_events_table() -> bool:
|
||||
table_exists = await check_table_exists("referral_contest_events")
|
||||
if table_exists:
|
||||
logger.info("Таблица referral_contest_events уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
referrer_id INTEGER NOT NULL,
|
||||
referral_id INTEGER NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
referrer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
referral_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE referral_contest_events (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
referrer_id INTEGER NOT NULL,
|
||||
referral_id INTEGER NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_referral_contest FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_referral_contest_referrer FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_referral_contest_referral FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX idx_referral_contest_referrer
|
||||
ON referral_contest_events (contest_id, referrer_id)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица referral_contest_events создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы referral_contest_events: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_contest_templates_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_templates")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_templates уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload TEXT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload JSON NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_templates (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT NULL,
|
||||
prize_days INTEGER NOT NULL DEFAULT 1,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
times_per_day INTEGER NOT NULL DEFAULT 1,
|
||||
schedule_times VARCHAR(255) NULL,
|
||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
||||
payload JSON NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_templates создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_templates: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_contest_rounds_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_rounds")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_rounds уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
ends_at DATETIME NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload TEXT NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id SERIAL PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL REFERENCES contest_templates(id) ON DELETE CASCADE,
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
ends_at TIMESTAMP NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload JSON NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_rounds (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
ends_at DATETIME NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
payload JSON NULL,
|
||||
winners_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_winners INTEGER NOT NULL DEFAULT 1,
|
||||
attempts_per_user INTEGER NOT NULL DEFAULT 1,
|
||||
message_id BIGINT NULL,
|
||||
chat_id BIGINT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_contest_round_template FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_round_status ON contest_rounds(status)"))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_rounds создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_rounds: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_contest_attempts_table() -> bool:
|
||||
table_exists = await check_table_exists("contest_attempts")
|
||||
if table_exists:
|
||||
logger.info("Таблица contest_attempts уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
round_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
round_id INTEGER NOT NULL REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE contest_attempts (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
round_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
answer TEXT NULL,
|
||||
is_winner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_contest_attempt_round FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_contest_attempt_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id)
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)"))
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Таблица contest_attempts создана")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания таблицы contest_attempts: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_type_column() -> bool:
|
||||
column_exists = await check_column_exists("referral_contests", "contest_type")
|
||||
if column_exists:
|
||||
logger.info("Колонка contest_type в referral_contests уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE referral_contests "
|
||||
"ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {db_type}")
|
||||
|
||||
logger.info("✅ Колонка contest_type в referral_contests добавлена")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления contest_type в referral_contests: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_discount_offer_columns():
|
||||
try:
|
||||
effect_exists = await check_column_exists('discount_offers', 'effect_type')
|
||||
@@ -3961,6 +4382,43 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===")
|
||||
contests_table_ready = await create_referral_contests_table()
|
||||
if contests_table_ready:
|
||||
logger.info("✅ Таблица referral_contests готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей referral_contests")
|
||||
|
||||
contest_events_ready = await create_referral_contest_events_table()
|
||||
if contest_events_ready:
|
||||
logger.info("✅ Таблица referral_contest_events готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей referral_contest_events")
|
||||
|
||||
contest_type_ready = await ensure_referral_contest_type_column()
|
||||
if contest_type_ready:
|
||||
logger.info("✅ Колонка contest_type для referral_contests готова")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось добавить contest_type в referral_contests")
|
||||
|
||||
contest_templates_ready = await create_contest_templates_table()
|
||||
if contest_templates_ready:
|
||||
logger.info("✅ Таблица contest_templates готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_templates")
|
||||
|
||||
contest_rounds_ready = await create_contest_rounds_table()
|
||||
if contest_rounds_ready:
|
||||
logger.info("✅ Таблица contest_rounds готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_rounds")
|
||||
|
||||
contest_attempts_ready = await create_contest_attempts_table()
|
||||
if contest_attempts_ready:
|
||||
logger.info("✅ Таблица contest_attempts готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_attempts")
|
||||
|
||||
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
|
||||
if user_discount_columns_ready:
|
||||
logger.info("✅ Колонки пользовательских промо-скидок готовы")
|
||||
@@ -4263,6 +4721,12 @@ async def check_migration_status():
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
"referral_contests_table": False,
|
||||
"referral_contest_events_table": False,
|
||||
"referral_contest_type_column": False,
|
||||
"contest_templates_table": False,
|
||||
"contest_rounds_table": False,
|
||||
"contest_attempts_table": False,
|
||||
"promo_offer_templates_table": False,
|
||||
"promo_offer_templates_active_discount_column": False,
|
||||
"promo_offer_logs_table": False,
|
||||
@@ -4286,6 +4750,12 @@ async def check_migration_status():
|
||||
status["discount_offers_table"] = await check_table_exists('discount_offers')
|
||||
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
|
||||
status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data')
|
||||
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["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')
|
||||
status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates')
|
||||
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
|
||||
@@ -4354,6 +4824,12 @@ async def check_migration_status():
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
|
||||
"referral_contests_table": "Таблица referral_contests",
|
||||
"referral_contest_events_table": "Таблица referral_contest_events",
|
||||
"referral_contest_type_column": "Колонка contest_type в referral_contests",
|
||||
"contest_templates_table": "Таблица contest_templates",
|
||||
"contest_rounds_table": "Таблица contest_rounds",
|
||||
"contest_attempts_table": "Таблица contest_attempts",
|
||||
"promo_offer_templates_table": "Таблица promo_offer_templates",
|
||||
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
|
||||
"promo_offer_logs_table": "Таблица promo_offer_logs",
|
||||
|
||||
@@ -5,6 +5,8 @@ from . import (
|
||||
bot_configuration,
|
||||
bulk_ban,
|
||||
campaigns,
|
||||
daily_contests,
|
||||
contests,
|
||||
faq,
|
||||
main,
|
||||
maintenance,
|
||||
@@ -33,4 +35,4 @@ from . import (
|
||||
user_messages,
|
||||
users,
|
||||
welcome_text,
|
||||
)
|
||||
)
|
||||
|
||||
549
app/handlers/admin/contests.py
Normal file
549
app/handlers/admin/contests.py
Normal file
@@ -0,0 +1,549 @@
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
create_referral_contest,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
toggle_referral_contest,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_admin_pagination_keyboard,
|
||||
get_contest_mode_keyboard,
|
||||
get_referral_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
|
||||
def _ensure_timezone(tz_name: str) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
|
||||
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
|
||||
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
|
||||
start_local = start_local.astimezone(tz)
|
||||
end_local = end_local.astimezone(tz)
|
||||
|
||||
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
|
||||
)
|
||||
|
||||
period = (
|
||||
f"{start_local.strftime('%d.%m %H:%M')} — "
|
||||
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
|
||||
)
|
||||
|
||||
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
|
||||
parts = [
|
||||
f"{status}",
|
||||
f"Период: <b>{period}</b>",
|
||||
f"Дневная сводка: <b>{summary_time}</b>",
|
||||
]
|
||||
if contest.prize_text:
|
||||
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
|
||||
if contest.last_daily_summary_date:
|
||||
parts.append(
|
||||
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
|
||||
date=contest.last_daily_summary_date.strftime("%d.%m")
|
||||
)
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
|
||||
try:
|
||||
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=tz)
|
||||
|
||||
|
||||
def _parse_time(value: str):
|
||||
try:
|
||||
return datetime.strptime(value.strip(), "%H:%M").time()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_root_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_contests_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
|
||||
reply_markup=get_admin_contests_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_CONTESTS_DISABLED",
|
||||
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_contests_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
|
||||
total = await get_referral_contests_count(db)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
|
||||
|
||||
if not contests:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for contest in contests:
|
||||
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
|
||||
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
lines.append(_format_contest_summary(contest, texts, contest_tz))
|
||||
lines.append("")
|
||||
|
||||
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
for contest in contests:
|
||||
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"🔎 {title}",
|
||||
callback_data=f"admin_contest_view_{contest.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
"admin_contests_list",
|
||||
back_callback="admin_contests",
|
||||
language=db_user.language,
|
||||
)
|
||||
keyboard_rows.extend(pagination.inline_keyboard)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_contest_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
|
||||
lines = [
|
||||
f"🏆 <b>{contest.title}</b>",
|
||||
_format_contest_summary(contest, texts, tz),
|
||||
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
|
||||
]
|
||||
|
||||
if contest.description:
|
||||
lines.append("")
|
||||
lines.append(contest.description)
|
||||
|
||||
if leaderboard:
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await toggle_referral_contest(db, contest, not contest.is_active)
|
||||
await show_contest_details(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_leaderboard(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not contest:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_contest_creation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await state.set_state(AdminStates.creating_referral_contest_mode)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_MODE_PROMPT",
|
||||
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
),
|
||||
reply_markup=get_contest_mode_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_contest_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
|
||||
await state.update_data(contest_type=mode)
|
||||
await state.set_state(AdminStates.creating_referral_contest_title)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
title = message.text.strip()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.update_data(title=title)
|
||||
await state.set_state(AdminStates.creating_referral_contest_description)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
description = message.text.strip()
|
||||
if description in {"-", "skip", "пропустить"}:
|
||||
description = None
|
||||
|
||||
await state.update_data(description=description)
|
||||
await state.set_state(AdminStates.creating_referral_contest_prize)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
prize = message.text.strip()
|
||||
if prize in {"-", "skip", "пропустить"}:
|
||||
prize = None
|
||||
|
||||
await state.update_data(prize=prize)
|
||||
await state.set_state(AdminStates.creating_referral_contest_start)
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_START",
|
||||
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
start_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not start_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(start_at=start_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_end)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_END",
|
||||
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
end_dt = _parse_local_datetime(message.text, tz)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not end_dt:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
start_raw = data.get("start_at")
|
||||
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
|
||||
if start_dt and end_dt <= start_dt:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_END_BEFORE_START",
|
||||
"Дата окончания должна быть позже даты начала.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(end_at=end_dt.isoformat())
|
||||
await state.set_state(AdminStates.creating_referral_contest_time)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME",
|
||||
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
summary_time = _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")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
tz = _ensure_timezone(settings.TIMEZONE)
|
||||
|
||||
start_at_raw = data.get("start_at")
|
||||
end_at_raw = data.get("end_at")
|
||||
if not start_at_raw or not end_at_raw:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
|
||||
return
|
||||
|
||||
start_at = (
|
||||
datetime.fromisoformat(start_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
end_at = (
|
||||
datetime.fromisoformat(end_at_raw)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
|
||||
contest_type = data.get("contest_type") or "referral_paid"
|
||||
|
||||
contest = await create_referral_contest(
|
||||
db,
|
||||
title=data.get("title"),
|
||||
description=data.get("description"),
|
||||
prize_text=data.get("prize"),
|
||||
contest_type=contest_type,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
daily_summary_time=summary_time,
|
||||
timezone_name=tz.key,
|
||||
created_by=db_user.id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest.id,
|
||||
is_active=contest.is_active,
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
|
||||
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
|
||||
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
|
||||
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
|
||||
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
|
||||
dp.callback_query.register(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"]))
|
||||
|
||||
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
|
||||
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
|
||||
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
|
||||
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
306
app/handlers/admin/daily_contests.py
Normal file
306
app/handlers/admin/daily_contests.py
Normal file
@@ -0,0 +1,306 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_template_by_id,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
create_round,
|
||||
)
|
||||
from app.database.models import ContestTemplate
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
get_admin_contests_root_keyboard,
|
||||
get_daily_contest_manage_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITABLE_FIELDS: Dict[str, Dict] = {
|
||||
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
|
||||
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
|
||||
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
|
||||
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
|
||||
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
|
||||
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
|
||||
}
|
||||
|
||||
|
||||
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
|
||||
return await get_template_by_id(db, template_id)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contests(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
templates = await list_templates(db, enabled_only=False)
|
||||
|
||||
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
|
||||
if not templates:
|
||||
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
|
||||
else:
|
||||
for tpl in templates:
|
||||
status = "🟢" if tpl.is_enabled else "⚪️"
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
|
||||
|
||||
keyboard_rows = []
|
||||
for tpl in templates:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"⚙️ {tpl.name}",
|
||||
callback_data=f"admin_daily_contest_{tpl.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_daily_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
template_id = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
lines = [
|
||||
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
|
||||
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
|
||||
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
|
||||
f"Попыток/польз: {tpl.attempts_per_user}",
|
||||
f"Раундов в день: {tpl.times_per_day}",
|
||||
f"Расписание: {tpl.schedule_times or '-'}",
|
||||
f"Длительность раунда: {tpl.cooldown_hours} ч.",
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(lines),
|
||||
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_daily_contest(
|
||||
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
|
||||
tpl.is_enabled = not tpl.is_enabled
|
||||
await db.commit()
|
||||
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await show_daily_contest(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_round_now(
|
||||
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
|
||||
|
||||
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 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(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
template_id = int(parts[3])
|
||||
field = parts[4]
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl or field not in EDITABLE_FIELDS:
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
await state.set_state(AdminStates.editing_daily_contest_field)
|
||||
await state.update_data(template_id=template_id, field=field)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_CONTEST_FIELD_PROMPT",
|
||||
"Введите новое значение для {label}:",
|
||||
).format(label=meta.get("label", field)),
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_field(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
field = data.get("field")
|
||||
if not template_id or not field or field not in EDITABLE_FIELDS:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
meta = EDITABLE_FIELDS[field]
|
||||
raw = message.text or ""
|
||||
try:
|
||||
if meta["type"] is int:
|
||||
value = int(raw)
|
||||
if meta.get("min") is not None and value < meta["min"]:
|
||||
raise ValueError("min")
|
||||
else:
|
||||
value = raw.strip()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, **{field: value})
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=None)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_payload(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
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
|
||||
|
||||
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)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
|
||||
reply_markup=None,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_payload(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
template_id = data.get("template_id")
|
||||
if not template_id:
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(message.text or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tpl = await _get_template(db, template_id)
|
||||
if not tpl:
|
||||
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_template_fields(db, tpl, payload=payload)
|
||||
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
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_round_now, F.data.startswith("admin_daily_start_"))
|
||||
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_"))
|
||||
|
||||
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
|
||||
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)
|
||||
373
app/handlers/contests.py
Normal file
373
app/handlers/contests.py
Normal file
@@ -0,0 +1,373 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_template_by_slug,
|
||||
get_active_round_by_template,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.states import ContestStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def _with_session() -> AsyncSession:
|
||||
return AsyncSessionLocal()
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
await extend_subscription(db, subscription, prize_days)
|
||||
texts = get_texts(language)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _ensure_round_for_template(template: ContestTemplate) -> Optional[ContestRound]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
round_obj = await get_active_round_by_template(db, template.id)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
|
||||
|
||||
# ---------- Handlers ----------
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
buttons = []
|
||||
for rnd in active_rounds:
|
||||
tpl_slug = rnd.template.slug if rnd.template else ""
|
||||
title = rnd.template.name if rnd.template else tpl_slug
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
if not buttons:
|
||||
buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
|
||||
)
|
||||
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
try:
|
||||
_, _, slug, round_id = callback.data.split("_", 3)
|
||||
round_id = int(round_id)
|
||||
except Exception:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
# reload round with template
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), 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)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
if tpl.slug == GAME_QUEST:
|
||||
await _render_quest(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
await _render_locks(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_SERVER:
|
||||
await _render_server_lottery(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_CIPHER:
|
||||
await _render_cipher(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_EMOJI:
|
||||
await _render_emoji(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_ANAGRAM:
|
||||
await _render_anagram(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
await _render_blitz(callback, db_user, round_obj, tpl)
|
||||
else:
|
||||
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
|
||||
|
||||
|
||||
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_{idx}"
|
||||
)
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
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)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
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 []
|
||||
keyboard = []
|
||||
row = []
|
||||
for idx, flag in enumerate(flags):
|
||||
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{idx}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "")
|
||||
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_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
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", "🤔")
|
||||
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),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
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_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
_, _, round_id_str, pick = callback.data.split("_", 3)
|
||||
round_id = int(round_id_str)
|
||||
except Exception:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
|
||||
return
|
||||
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
is_winner = False
|
||||
if tpl.slug in {GAME_QUEST, GAME_LOCKS, GAME_SERVER}:
|
||||
try:
|
||||
pick_int = int(pick)
|
||||
is_winner = pick_int == secret_idx
|
||||
except Exception:
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = True # первый клик получит
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
|
||||
else:
|
||||
responses = {
|
||||
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
|
||||
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
|
||||
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
|
||||
}.get(tpl.slug, ["Неудача"])
|
||||
await callback.answer(random.choice(responses), show_alert=False)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
attempt = await get_attempt(db2, round_obj.id, db_user.id)
|
||||
if attempt:
|
||||
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
answer = (message.text or "").strip().upper()
|
||||
tpl = round_obj.template
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
|
||||
|
||||
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_"))
|
||||
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
|
||||
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
|
||||
dp.message.register(lambda message: None, Command("contests")) # placeholder
|
||||
@@ -95,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"),
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
@@ -471,6 +477,148 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"),
|
||||
callback_data="admin_contests_referral",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"),
|
||||
callback_data="admin_contests_daily",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"),
|
||||
callback_data="admin_contests_list",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTESTS_CREATE", "➕ Новый конкурс"),
|
||||
callback_data="admin_contests_create",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_contests",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"),
|
||||
callback_data="admin_contest_mode_paid",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑🤝🧑 Просто реферал"),
|
||||
callback_data="admin_contest_mode_registered",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral")
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_daily_contest_manage_keyboard(
|
||||
template_id: int,
|
||||
is_enabled: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
return InlineKeyboardMarkup(
|
||||
inline_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_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"),
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
def get_referral_contest_manage_keyboard(
|
||||
contest_id: int,
|
||||
*,
|
||||
is_active: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
toggle_text = (
|
||||
_t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить")
|
||||
if is_active
|
||||
else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
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",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(
|
||||
campaign_id: int, is_active: bool, language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
@@ -48,6 +48,71 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
|
||||
"ADMIN_CONTESTS": "🏆 Contests",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ New contest",
|
||||
"ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Current contests",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Daily contests",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Coming soon.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Contests</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\n\nChoose an action:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Referral registration",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):",
|
||||
"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_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.",
|
||||
"ADMIN_CONTEST_CREATED": "Contest created!",
|
||||
"ADMIN_CONTEST_PRIZE": "Prize: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Leaders",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Start",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Pause",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Contest not found.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Prize (days)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Winners",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Attempts",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Rounds/day",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Schedule",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Duration",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:",
|
||||
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:",
|
||||
"ADMIN_ROUND_STARTED": "Round started",
|
||||
"ADMIN_UPDATED": "Updated",
|
||||
"ADMIN_INVALID_NUMBER": "Invalid number",
|
||||
"ADMIN_INVALID_JSON": "Invalid JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Games/Contests</b>\nChoose a game:",
|
||||
"CONTEST_EMPTY": "No games right now",
|
||||
"CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.",
|
||||
"CONTEST_ROUND_FINISHED": "Round finished or unavailable.",
|
||||
"CONTEST_ALREADY_PLAYED": "You already played this round.",
|
||||
"CONTEST_UNKNOWN": "Contest type not supported.",
|
||||
"CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:",
|
||||
"CONTEST_LOCKS_PROMPT": "Find the hacked lock:",
|
||||
"CONTEST_SERVER_PROMPT": "Choose a server:",
|
||||
"CONTEST_CIPHER_PROMPT": "Decode: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Guess the service: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”",
|
||||
"CONTEST_BLITZ_BUTTON": "I'm here!",
|
||||
"CONTEST_WIN": "🎉 You win! ",
|
||||
"CONTEST_LOSE": "Wrong, try next round.",
|
||||
"CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!",
|
||||
"ADMIN_CANCEL": "❌ Cancel",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
|
||||
|
||||
@@ -48,6 +48,71 @@
|
||||
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
|
||||
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
|
||||
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
|
||||
"ADMIN_CONTESTS": "🏆 Конкурсы",
|
||||
"ADMIN_CONTESTS_CREATE": "➕ Новый конкурс",
|
||||
"ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
|
||||
"ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы",
|
||||
"ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы",
|
||||
"ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.",
|
||||
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Конкурсы</b>\n",
|
||||
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
|
||||
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\n\nВыберите действие:",
|
||||
"ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой",
|
||||
"ADMIN_CONTEST_MODE_REGISTERED": "🧑🤝🧑 Просто реферал",
|
||||
"ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
|
||||
"ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:",
|
||||
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):",
|
||||
"ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
|
||||
"ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
|
||||
"ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00",
|
||||
"ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00",
|
||||
"ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.",
|
||||
"ADMIN_CONTEST_CREATED": "Конкурс создан!",
|
||||
"ADMIN_CONTEST_PRIZE": "Приз: {prize}",
|
||||
"ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}",
|
||||
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен",
|
||||
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен",
|
||||
"ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: <b>{count}</b>",
|
||||
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:",
|
||||
"ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры",
|
||||
"ADMIN_CONTEST_ENABLE": "▶️ Запустить",
|
||||
"ADMIN_CONTEST_DISABLE": "⏸️ Остановить",
|
||||
"ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.",
|
||||
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.",
|
||||
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы",
|
||||
"ADMIN_EDIT_PRIZE": "🏅 Приз (дни)",
|
||||
"ADMIN_EDIT_MAX_WINNERS": "👥 Победителей",
|
||||
"ADMIN_EDIT_ATTEMPTS": "🔁 Попытки",
|
||||
"ADMIN_EDIT_TIMES": "⏰ Раундов/день",
|
||||
"ADMIN_EDIT_SCHEDULE": "🕒 Расписание",
|
||||
"ADMIN_EDIT_COOLDOWN": "⌛ Длительность",
|
||||
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
|
||||
"ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:",
|
||||
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Отправьте JSON payload для игры (словарь настроек):",
|
||||
"ADMIN_ROUND_STARTED": "Раунд запущен",
|
||||
"ADMIN_UPDATED": "Обновлено",
|
||||
"ADMIN_INVALID_NUMBER": "Некорректное число",
|
||||
"ADMIN_INVALID_JSON": "Некорректный JSON",
|
||||
"CONTEST_MENU_TITLE": "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:",
|
||||
"CONTEST_EMPTY": "Сейчас игр нет",
|
||||
"CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.",
|
||||
"CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.",
|
||||
"CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.",
|
||||
"CONTEST_UNKNOWN": "Тип конкурса не поддерживается.",
|
||||
"CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:",
|
||||
"CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:",
|
||||
"CONTEST_SERVER_PROMPT": "Выбери сервер:",
|
||||
"CONTEST_CIPHER_PROMPT": "Расшифруй: {q}",
|
||||
"CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}",
|
||||
"CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}",
|
||||
"CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»",
|
||||
"CONTEST_BLITZ_BUTTON": "Я здесь!",
|
||||
"CONTEST_WIN": "🎉 Победа! ",
|
||||
"CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.",
|
||||
"CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!",
|
||||
"ADMIN_CANCEL": "❌ Отмена",
|
||||
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
|
||||
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
|
||||
|
||||
247
app/services/contest_rotation_service.py
Normal file
247
app/services/contest_rotation_service.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_round,
|
||||
get_active_round_by_template,
|
||||
list_templates,
|
||||
upsert_template,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Slugs for games
|
||||
GAME_QUEST = "quest_buttons"
|
||||
GAME_LOCKS = "lock_hack"
|
||||
GAME_CIPHER = "letter_cipher"
|
||||
GAME_SERVER = "server_lottery"
|
||||
GAME_BLITZ = "blitz_reaction"
|
||||
GAME_EMOJI = "emoji_guess"
|
||||
GAME_ANAGRAM = "anagram"
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
{
|
||||
"slug": GAME_QUEST,
|
||||
"name": "Квест-кнопки",
|
||||
"description": "Найди секретную кнопку 3×3",
|
||||
"prize_days": 1,
|
||||
"max_winners": 3,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "10:00,18:00",
|
||||
"payload": {"rows": 3, "cols": 3},
|
||||
},
|
||||
{
|
||||
"slug": GAME_LOCKS,
|
||||
"name": "Кнопочный взлом",
|
||||
"description": "Найди взломанную кнопку среди 20 замков",
|
||||
"prize_days": 5,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "09:00,19:00",
|
||||
"payload": {"buttons": 20},
|
||||
},
|
||||
{
|
||||
"slug": GAME_CIPHER,
|
||||
"name": "Шифр букв",
|
||||
"description": "Расшифруй слово по номерам",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "12:00,20:00",
|
||||
"payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]},
|
||||
},
|
||||
{
|
||||
"slug": GAME_SERVER,
|
||||
"name": "Сервер-лотерея",
|
||||
"description": "Угадай доступный сервер",
|
||||
"prize_days": 7,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "15:00",
|
||||
"payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]},
|
||||
},
|
||||
{
|
||||
"slug": GAME_BLITZ,
|
||||
"name": "Блиц-реакция",
|
||||
"description": "Нажми кнопку за 10 секунд",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
"schedule_times": "11:00,21:00",
|
||||
"payload": {"timeout_seconds": 10},
|
||||
},
|
||||
{
|
||||
"slug": GAME_EMOJI,
|
||||
"name": "Угадай сервис по эмодзи",
|
||||
"description": "Определи сервис по эмодзи",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "13:00",
|
||||
"payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]},
|
||||
},
|
||||
{
|
||||
"slug": GAME_ANAGRAM,
|
||||
"name": "Анаграмма дня",
|
||||
"description": "Собери слово из букв",
|
||||
"prize_days": 1,
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
"schedule_times": "17:00",
|
||||
"payload": {"words": ["SERVER", "XRAY", "VPN"]},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ContestRotationService:
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._interval_seconds = 60
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
logger.info("Сервис игр отключён настройками")
|
||||
return
|
||||
|
||||
await self._ensure_default_templates()
|
||||
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info("🎲 Сервис ротационных конкурсов запущен")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _ensure_default_templates(self) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
for tpl in DEFAULT_TEMPLATES:
|
||||
try:
|
||||
await upsert_template(db, **tpl)
|
||||
except Exception as exc:
|
||||
logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc)
|
||||
|
||||
async def _loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка в ротации конкурсов: %s", exc)
|
||||
await asyncio.sleep(self._interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис ротации конкурсов остановлен")
|
||||
raise
|
||||
|
||||
def _parse_times(self, times_str: Optional[str]) -> List[time]:
|
||||
if not times_str:
|
||||
return []
|
||||
times: List[time] = []
|
||||
for part in times_str.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
hh, mm = part.split(":")
|
||||
times.append(time(int(hh), int(mm)))
|
||||
except Exception:
|
||||
continue
|
||||
return times
|
||||
|
||||
async def _tick(self) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
templates = await list_templates(db)
|
||||
now_local = datetime.now().astimezone(timezone.utc)
|
||||
for tpl in templates:
|
||||
times = self._parse_times(tpl.schedule_times) or []
|
||||
for slot in times[: tpl.times_per_day]:
|
||||
starts_at_local = now_local.replace(
|
||||
hour=slot.hour, minute=slot.minute, second=0, microsecond=0
|
||||
)
|
||||
if starts_at_local > now_local:
|
||||
starts_at_local -= timedelta(days=1)
|
||||
ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours)
|
||||
if not (starts_at_local <= now_local <= ends_at_local):
|
||||
continue
|
||||
|
||||
exists = await get_active_round_by_template(db, tpl.id)
|
||||
if exists:
|
||||
continue
|
||||
payload = self._build_payload_for_template(tpl)
|
||||
round_obj = await create_round(
|
||||
db,
|
||||
template=tpl,
|
||||
starts_at=starts_at_local.replace(tzinfo=None),
|
||||
ends_at=ends_at_local.replace(tzinfo=None),
|
||||
payload=payload,
|
||||
)
|
||||
logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug)
|
||||
|
||||
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
||||
payload = tpl.payload or {}
|
||||
if tpl.slug == GAME_QUEST:
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_LOCKS:
|
||||
total = payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_CIPHER:
|
||||
words = payload.get("words") or ["VPN"]
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_BLITZ:
|
||||
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
|
||||
if tpl.slug == GAME_EMOJI:
|
||||
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
if tpl.slug == GAME_ANAGRAM:
|
||||
words = payload.get("words") or ["SERVER"]
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
return payload
|
||||
|
||||
|
||||
contest_rotation_service = ContestRotationService()
|
||||
479
app/services/referral_contest_service.py
Normal file
479
app/services/referral_contest_service.py
Normal file
@@ -0,0 +1,479 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, date, time, timedelta, timezone
|
||||
from typing import Optional, Sequence, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramNotFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_contest_event,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contests_for_events,
|
||||
get_contests_for_summaries,
|
||||
get_referrer_score,
|
||||
mark_daily_summary_sent,
|
||||
mark_final_summary_sent,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ReferralContest, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferralContestService:
|
||||
def __init__(self) -> None:
|
||||
self.bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._poll_interval_seconds = 60
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
if not settings.is_contests_enabled():
|
||||
logger.info("Сервис конкурсов отключен настройками")
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.warning("Невозможно запустить сервис конкурсов без экземпляра бота")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("🏆 Сервис конкурсов запущен")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
await self._process_summaries()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка сервиса конкурсов: %s", exc)
|
||||
|
||||
await asyncio.sleep(self._poll_interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Сервис конкурсов остановлен")
|
||||
raise
|
||||
|
||||
async def _process_summaries(self) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
contests = await get_contests_for_summaries(db)
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
await self._maybe_send_daily_summary(db, contest, now_utc)
|
||||
await self._maybe_send_final_summary(db, contest, now_utc)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Ошибка обработки конкурса %s (%s): %s",
|
||||
contest.id,
|
||||
contest.title,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _maybe_send_daily_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
) -> None:
|
||||
tz = self._get_timezone(contest)
|
||||
now_local = now_utc.replace(tzinfo=timezone.utc).astimezone(tz)
|
||||
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
|
||||
|
||||
if contest.last_daily_summary_date == now_local.date():
|
||||
return
|
||||
|
||||
await self._send_summary(db, contest, now_utc, now_local.date(), is_final=False)
|
||||
|
||||
async def _maybe_send_final_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
) -> None:
|
||||
if contest.final_summary_sent:
|
||||
return
|
||||
|
||||
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_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz)
|
||||
summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if now_utc < contest.end_at:
|
||||
return
|
||||
|
||||
if now_utc < summary_dt_utc:
|
||||
return
|
||||
|
||||
await self._send_summary(db, contest, now_utc, end_local.date(), is_final=True)
|
||||
|
||||
async def _send_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
contest: ReferralContest,
|
||||
now_utc: datetime,
|
||||
target_date: date,
|
||||
*,
|
||||
is_final: bool,
|
||||
) -> None:
|
||||
tz = self._get_timezone(contest)
|
||||
day_start_local = datetime.combine(target_date, time.min, tzinfo=tz)
|
||||
day_end_local = day_start_local + timedelta(days=1)
|
||||
day_start_utc = day_start_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
day_end_utc = day_end_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
leaderboard = list(await get_contest_leaderboard(db, contest.id))
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
today_events = await get_contest_events_count(
|
||||
db,
|
||||
contest.id,
|
||||
start=day_start_utc,
|
||||
end=day_end_utc,
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
contest=contest,
|
||||
leaderboard=leaderboard,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
await self._notify_public_channel(
|
||||
contest=contest,
|
||||
leaderboard=leaderboard,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
if not leaderboard:
|
||||
logger.info("Конкурс %s: пока нет участников", contest.id)
|
||||
|
||||
if is_final:
|
||||
await mark_final_summary_sent(db, contest)
|
||||
else:
|
||||
await mark_daily_summary_sent(db, contest, target_date)
|
||||
|
||||
async def _notify_participants(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
day_start_utc: datetime,
|
||||
day_end_utc: datetime,
|
||||
is_final: bool,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
# leaderboard already sorted by helper
|
||||
score_map = {user.id: (idx + 1, score) for idx, (user, score, _) in enumerate(leaderboard)}
|
||||
|
||||
for user, score, _ in leaderboard:
|
||||
rank = score_map.get(user.id, (None, score))[0]
|
||||
today_score = await get_referrer_score(
|
||||
db=db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.id,
|
||||
start=day_start_utc,
|
||||
end=day_end_utc,
|
||||
) if score else 0
|
||||
|
||||
text = self._build_participant_message(
|
||||
contest=contest,
|
||||
rank=rank or 0,
|
||||
score=score,
|
||||
today_score=today_score,
|
||||
total_events=total_events,
|
||||
today_events=today_events,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True)
|
||||
except (TelegramForbiddenError, TelegramNotFound):
|
||||
logger.info(
|
||||
"Не удалось отправить сообщение участнику %s (вероятно, блокировка)",
|
||||
user.telegram_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка отправки участнику конкурса %s: %s", user.telegram_id, exc)
|
||||
|
||||
async def _notify_admins(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
tz: ZoneInfo,
|
||||
) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
chat_id = settings.ADMIN_NOTIFICATIONS_CHAT_ID
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
lines = [
|
||||
"🏆 <b>Конкурс рефералов</b>",
|
||||
f"Название: <b>{contest.title}</b>",
|
||||
f"Статус: {'финал' if is_final else 'дневная сводка'}",
|
||||
f"Временная зона: <code>{tz.key}</code>",
|
||||
f"Зачётов всего: <b>{total_events}</b>, сегодня: <b>{today_events}</b>",
|
||||
"",
|
||||
"Топ участников:",
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
name = user.full_name
|
||||
lines.append(f"{idx}. {name} — {score}")
|
||||
else:
|
||||
lines.append("Пока нет участников.")
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Приз: {contest.prize_text}")
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text="\n".join(lines),
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось отправить админскую сводку конкурса: %s", exc)
|
||||
|
||||
async def _notify_public_channel(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[Tuple[User, int, int]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
tz: ZoneInfo,
|
||||
) -> 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
|
||||
|
||||
lines = [
|
||||
f"🏆 {contest.title}",
|
||||
"🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги",
|
||||
f"Время зоны: {tz.key}",
|
||||
f"Зачётов всего: <b>{total_events}</b> • Сегодня: <b>{today_events}</b>",
|
||||
"",
|
||||
"Топ участников:",
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f"{idx}. {user.full_name} — {score}")
|
||||
else:
|
||||
lines.append("Пока нет участников.")
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Приз: {contest.prize_text}")
|
||||
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=channel_id,
|
||||
text="\n".join(lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except (TelegramForbiddenError, TelegramNotFound):
|
||||
logger.info("Не удалось отправить сводку конкурса в канал %s", channel_id_raw)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ошибка отправки сводки конкурса в канал %s: %s", channel_id_raw, exc)
|
||||
|
||||
def _build_participant_message(
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
rank: int,
|
||||
score: int,
|
||||
today_score: int,
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
) -> str:
|
||||
status_line = "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги"
|
||||
lines = [
|
||||
f"🏆 {contest.title}",
|
||||
status_line,
|
||||
"",
|
||||
f"Ваше место: <b>{rank}</b>",
|
||||
f"Зачётов за всё время: <b>{score}</b>",
|
||||
f"За сегодня: <b>{today_score}</b>",
|
||||
f"Общий пул зачётов: <b>{total_events}</b> (сегодня {today_events})",
|
||||
]
|
||||
|
||||
if contest.prize_text:
|
||||
lines.append("")
|
||||
lines.append(f"Призовой фонд: {contest.prize_text}")
|
||||
|
||||
if not is_final:
|
||||
remaining = contest.end_at - datetime.now(timezone.utc)
|
||||
if remaining.total_seconds() > 0:
|
||||
hours_left = int(remaining.total_seconds() // 3600)
|
||||
lines.append("")
|
||||
lines.append(f"До окончания: ~{hours_left} ч.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_timezone(self, contest: ReferralContest) -> ZoneInfo:
|
||||
tz_name = contest.timezone or settings.TIMEZONE
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime:
|
||||
base = dt_value
|
||||
if dt_value.tzinfo is None:
|
||||
base = dt_value.replace(tzinfo=timezone.utc)
|
||||
return base.astimezone(tz)
|
||||
|
||||
async def on_subscription_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
) -> None:
|
||||
if not settings.is_contests_enabled():
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
contests = await get_contests_for_events(
|
||||
db,
|
||||
now_utc,
|
||||
contest_types=["referral_paid"],
|
||||
)
|
||||
if not contests:
|
||||
return
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
event = await add_contest_event(
|
||||
db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type="subscription_purchase",
|
||||
)
|
||||
if event:
|
||||
logger.info(
|
||||
"Записан зачёт конкурса %s: реферер %s, реферал %s",
|
||||
contest.id,
|
||||
user.referred_by_id,
|
||||
user.id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось записать зачёт конкурса %s: %s", contest.id, exc)
|
||||
|
||||
async def on_referral_registration(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
if not settings.is_contests_enabled():
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
contests = await get_contests_for_events(
|
||||
db,
|
||||
now_utc,
|
||||
contest_types=["referral_registered"],
|
||||
)
|
||||
if not contests:
|
||||
return
|
||||
|
||||
for contest in contests:
|
||||
try:
|
||||
event = await add_contest_event(
|
||||
db,
|
||||
contest_id=contest.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=0,
|
||||
event_type="referral_registration",
|
||||
)
|
||||
if event:
|
||||
logger.info(
|
||||
"Записан зачёт конкурса регистрации %s: реферер %s, реферал %s",
|
||||
contest.id,
|
||||
user.referred_by_id,
|
||||
user.id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc)
|
||||
|
||||
referral_contest_service = ReferralContestService()
|
||||
@@ -50,6 +50,13 @@ async def process_referral_registration(
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_referral_registration(db, new_user_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Не удалось записать конкурсную регистрацию: %s", exc)
|
||||
|
||||
if bot:
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
referral_notification = (
|
||||
|
||||
@@ -90,6 +90,16 @@ class AdminStates(StatesGroup):
|
||||
editing_promo_group_device_discount = State()
|
||||
editing_promo_group_period_discount = State()
|
||||
editing_promo_group_auto_assign = State()
|
||||
|
||||
creating_referral_contest_title = State()
|
||||
creating_referral_contest_description = State()
|
||||
creating_referral_contest_prize = State()
|
||||
creating_referral_contest_mode = State()
|
||||
creating_referral_contest_start = State()
|
||||
creating_referral_contest_end = State()
|
||||
creating_referral_contest_time = State()
|
||||
editing_daily_contest_field = State()
|
||||
editing_daily_contest_value = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
@@ -190,6 +200,9 @@ class SquadMigrationStates(StatesGroup):
|
||||
class RemnaWaveSyncStates(StatesGroup):
|
||||
waiting_for_schedule = State()
|
||||
|
||||
class ContestStates(StatesGroup):
|
||||
waiting_for_answer = State()
|
||||
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
|
||||
@@ -45,6 +45,23 @@ def admin_required(func: Callable) -> Callable:
|
||||
return wrapper
|
||||
|
||||
|
||||
def auth_required(func: Callable) -> Callable:
|
||||
"""
|
||||
Простая проверка на наличие пользователя в апдейте. Middleware уже подтягивает db_user,
|
||||
но здесь страхуемся от вызовов без from_user.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
|
||||
user = None
|
||||
if isinstance(event, (types.Message, types.CallbackQuery)):
|
||||
user = event.from_user
|
||||
if not user:
|
||||
logger.warning("auth_required: нет from_user, пропускаем")
|
||||
return
|
||||
return await func(event, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def error_handler(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8080}:8080"
|
||||
networks:
|
||||
- bot_network
|
||||
- remnawave-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
|
||||
interval: 60s
|
||||
@@ -89,9 +89,7 @@ volumes:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
remnawave-network:
|
||||
name: remnawave-network
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
46
main.py
46
main.py
@@ -32,6 +32,8 @@ from app.localization.loader import ensure_locale_templates
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
|
||||
@@ -174,6 +176,7 @@ async def main():
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
referral_contest_service.set_bot(bot)
|
||||
stage.log(f"Репозиторий версий: {version_service.repo}")
|
||||
stage.log(f"Текущая версия: {version_service.current_version}")
|
||||
stage.success("Мониторинг, уведомления и рассылки подключены")
|
||||
@@ -211,6 +214,37 @@ async def main():
|
||||
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Реферальные конкурсы",
|
||||
"🏆",
|
||||
success_message="Сервис конкурсов готов",
|
||||
) as stage:
|
||||
try:
|
||||
await referral_contest_service.start()
|
||||
if referral_contest_service.is_running():
|
||||
stage.log("Автосводки по конкурсам запущены")
|
||||
else:
|
||||
stage.skip("Сервис конкурсов выключен настройками")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска сервиса конкурсов: {e}")
|
||||
logger.error(f"❌ Ошибка запуска сервиса конкурсов: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Ротация игр",
|
||||
"🎲",
|
||||
success_message="Мини-игры готовы",
|
||||
) as stage:
|
||||
try:
|
||||
contest_rotation_service.set_bot(bot)
|
||||
await contest_rotation_service.start()
|
||||
if contest_rotation_service.is_running():
|
||||
stage.log("Ротационные игры запущены")
|
||||
else:
|
||||
stage.skip("Ротация игр выключена настройками")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска ротации игр: {e}")
|
||||
logger.error(f"❌ Ошибка запуска ротации игр: {e}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Автосинхронизация RemnaWave",
|
||||
"🔄",
|
||||
@@ -594,12 +628,24 @@ async def main():
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса отчетов: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса конкурсов...")
|
||||
try:
|
||||
await referral_contest_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса конкурсов: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...")
|
||||
try:
|
||||
await remnawave_sync_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка ротации игр...")
|
||||
try:
|
||||
await contest_rotation_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки ротации игр: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
|
||||
BIN
vpn_logo.png
BIN
vpn_logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 182 KiB |
Reference in New Issue
Block a user