+187
-134
@@ -127,152 +127,192 @@ async def get_transactions(
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_payment_methods(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> list[PaymentMethodResponse]:
|
||||
"""Get available payment methods filtered by DB config and user context.
|
||||
|
||||
Combines env-var availability with DB-based admin config (ordering, display conditions).
|
||||
"""
|
||||
from app.services.payment_method_config_service import (
|
||||
_get_method_defaults,
|
||||
get_all_configs,
|
||||
)
|
||||
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
|
||||
# Provider availability checks from env vars
|
||||
provider_enabled = {
|
||||
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
|
||||
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
|
||||
'cryptobot': settings.is_cryptobot_enabled(),
|
||||
'heleket': settings.is_heleket_enabled(),
|
||||
'yookassa': settings.is_yookassa_enabled(),
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
'pal24': settings.is_pal24_enabled(),
|
||||
'platega': settings.is_platega_enabled(),
|
||||
'wata': settings.is_wata_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
'kassa_ai': settings.is_kassa_ai_enabled(),
|
||||
}
|
||||
|
||||
# Default options builder (for methods with sub-options)
|
||||
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
|
||||
if method_id == 'yookassa':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
|
||||
]
|
||||
elif method_id == 'pal24':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'platega':
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
all_opts = []
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
all_opts.append(
|
||||
{
|
||||
'id': str(method_code),
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
'description': info.get('description') or info.get('name') or '',
|
||||
}
|
||||
)
|
||||
elif method_id == 'freekassa':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'cloudpayments':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not all_opts:
|
||||
return None
|
||||
|
||||
# Filter by sub_options config from DB
|
||||
if config_sub_options:
|
||||
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
|
||||
|
||||
return all_opts if all_opts else None
|
||||
|
||||
# User promo group IDs for filtering
|
||||
user_promo_group_ids: set[int] = set()
|
||||
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
|
||||
for upg in user.user_promo_groups:
|
||||
user_promo_group_ids.add(upg.promo_group_id)
|
||||
if hasattr(user, 'promo_group_id') and user.promo_group_id:
|
||||
user_promo_group_ids.add(user.promo_group_id)
|
||||
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods():
|
||||
"""Get available payment methods."""
|
||||
methods = []
|
||||
for config in configs:
|
||||
mid = config.method_id
|
||||
|
||||
# 1. Check env-var provider availability AND DB admin toggle
|
||||
if not provider_enabled.get(mid, False):
|
||||
continue
|
||||
if not config.is_enabled:
|
||||
continue
|
||||
# YooKassa - with card and SBP options
|
||||
if settings.is_yookassa_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='yookassa',
|
||||
name=settings.get_yookassa_display_name(),
|
||||
description='Pay via YooKassa',
|
||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
options=[
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Check user type filter
|
||||
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
|
||||
continue
|
||||
if config.user_type_filter == 'email' and user.auth_type != 'email':
|
||||
continue
|
||||
# CryptoBot
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='cryptobot',
|
||||
name=settings.get_cryptobot_display_name(),
|
||||
description='Pay with cryptocurrency via CryptoBot',
|
||||
min_amount_kopeks=1000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# 3. Check first topup filter
|
||||
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
|
||||
continue
|
||||
if config.first_topup_filter == 'no' and user.has_made_first_topup:
|
||||
continue
|
||||
# Telegram Stars
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='telegram_stars',
|
||||
name=settings.get_telegram_stars_display_name(),
|
||||
description='Pay with Telegram Stars',
|
||||
min_amount_kopeks=100,
|
||||
max_amount_kopeks=1000000,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Check promo group filter
|
||||
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
|
||||
if not user_promo_group_ids.intersection(allowed_ids):
|
||||
continue
|
||||
# Heleket
|
||||
if settings.is_heleket_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='heleket',
|
||||
name=settings.get_heleket_display_name(),
|
||||
description='Pay with cryptocurrency via Heleket',
|
||||
min_amount_kopeks=1000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Build the response
|
||||
method_def = defaults.get(mid, {})
|
||||
display_name = config.display_name or method_def.get('default_display_name', mid)
|
||||
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
|
||||
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
|
||||
options = _build_options(mid, config.sub_options)
|
||||
# MulenPay
|
||||
if settings.is_mulenpay_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='mulenpay',
|
||||
name=settings.get_mulenpay_display_name(),
|
||||
description='MulenPay payment',
|
||||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# PAL24 - add options for card/sbp
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='pal24',
|
||||
name=settings.get_pal24_display_name(),
|
||||
description='Pay via PAL24',
|
||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
options=[
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Platega - add options for different payment methods
|
||||
if settings.is_platega_enabled():
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
platega_options = []
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
platega_options.append(
|
||||
{
|
||||
'id': str(method_code),
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
'description': info.get('description') or info.get('name') or '',
|
||||
}
|
||||
)
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id=mid,
|
||||
name=display_name,
|
||||
description=None,
|
||||
min_amount_kopeks=min_amount,
|
||||
max_amount_kopeks=max_amount,
|
||||
id='platega',
|
||||
name=settings.get_platega_display_name(),
|
||||
description='Pay via Platega',
|
||||
min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
options=platega_options if platega_options else None,
|
||||
)
|
||||
)
|
||||
|
||||
# Wata
|
||||
if settings.is_wata_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='wata',
|
||||
name=settings.get_wata_display_name(),
|
||||
description='Pay via Wata',
|
||||
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# CloudPayments
|
||||
if settings.is_cloudpayments_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='cloudpayments',
|
||||
name=settings.get_cloudpayments_display_name(),
|
||||
description='Pay with bank card via CloudPayments',
|
||||
min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# FreeKassa
|
||||
if settings.is_freekassa_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='freekassa',
|
||||
name=settings.get_freekassa_display_name(),
|
||||
description='Pay via FreeKassa',
|
||||
min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# KassaAI
|
||||
if settings.is_kassa_ai_enabled():
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='kassa_ai',
|
||||
name=settings.get_kassa_ai_display_name(),
|
||||
description='Pay via KassaAI',
|
||||
min_amount_kopeks=settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Tribute
|
||||
if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK:
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='tribute',
|
||||
name='Tribute',
|
||||
description='Pay with bank card via Tribute',
|
||||
min_amount_kopeks=10000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
|
||||
return methods
|
||||
|
||||
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods."""
|
||||
return await _get_available_payment_methods(db, user)
|
||||
|
||||
|
||||
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
|
||||
async def create_stars_invoice(
|
||||
request: StarsInvoiceRequest,
|
||||
@@ -374,7 +414,7 @@ async def create_topup(
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
# Validate payment method
|
||||
methods = await _get_available_payment_methods(db, user)
|
||||
methods = await get_payment_methods()
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
|
||||
if not method or not method.is_available:
|
||||
@@ -705,7 +745,7 @@ async def create_topup(
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Kassa AI payment method is unavailable',
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
@@ -724,7 +764,7 @@ async def create_topup(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Kassa AI payment',
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
@@ -867,6 +907,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -895,6 +946,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -918,7 +971,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events(
|
||||
'contest_start': contest_start.isoformat(),
|
||||
'contest_end': contest_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def add_virtual_participant(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
display_name: str,
|
||||
referral_count: int,
|
||||
total_amount_kopeks: int = 0,
|
||||
) -> ReferralContestVirtualParticipant:
|
||||
vp = ReferralContestVirtualParticipant(
|
||||
contest_id=contest_id,
|
||||
display_name=display_name,
|
||||
referral_count=referral_count,
|
||||
total_amount_kopeks=total_amount_kopeks,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def list_virtual_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[ReferralContestVirtualParticipant]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant)
|
||||
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
|
||||
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_virtual_participant(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return False
|
||||
await db.delete(vp)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def update_virtual_participant_count(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
referral_count: int,
|
||||
) -> ReferralContestVirtualParticipant | None:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return None
|
||||
vp.referral_count = referral_count
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def get_contest_leaderboard_with_virtual(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> list[tuple[str, int, int, bool]]:
|
||||
"""Лидерборд с виртуальными участниками.
|
||||
|
||||
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
|
||||
"""
|
||||
real = await get_contest_leaderboard(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
|
||||
merged: list[tuple[str, int, int, bool]] = []
|
||||
for user, score, amount in real:
|
||||
merged.append((user.full_name, score, amount, False))
|
||||
for vp in virtual:
|
||||
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
|
||||
|
||||
merged.sort(key=lambda x: (-x[1], -x[2]))
|
||||
|
||||
if limit:
|
||||
merged = merged[:limit]
|
||||
|
||||
return merged
|
||||
|
||||
@@ -1548,6 +1548,24 @@ class ReferralContestEvent(Base):
|
||||
)
|
||||
|
||||
|
||||
class ReferralContestVirtualParticipant(Base):
|
||||
__tablename__ = 'referral_contest_virtual_participants'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
|
||||
display_name = Column(String(255), nullable=False)
|
||||
referral_count = Column(Integer, nullable=False, default=0)
|
||||
total_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
contest = relationship('ReferralContest')
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = 'contest_templates'
|
||||
|
||||
|
||||
@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contest_virtual_participants_table() -> bool:
|
||||
table_exists = await check_table_exists('referral_contest_virtual_participants')
|
||||
if table_exists:
|
||||
logger.info('Таблица referral_contest_virtual_participants уже существует')
|
||||
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_virtual_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
contest_id INT NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INT NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants создана')
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_summary_columns() -> bool:
|
||||
ok = True
|
||||
for column in ['daily_summary_times', 'last_daily_summary_at']:
|
||||
@@ -6459,6 +6518,12 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
|
||||
|
||||
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
|
||||
if virtual_participants_ready:
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
|
||||
|
||||
contest_type_ready = await ensure_referral_contest_type_column()
|
||||
if contest_type_ready:
|
||||
logger.info('✅ Колонка contest_type для referral_contests готова')
|
||||
|
||||
@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_virtual_participant,
|
||||
create_referral_contest,
|
||||
delete_referral_contest,
|
||||
delete_virtual_participant,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contest_leaderboard_with_virtual,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
list_virtual_participants,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
update_virtual_participant_count,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
@@ -240,8 +244,10 @@ async def show_contest_details(
|
||||
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)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
|
||||
virtual_list = await list_virtual_participants(db, contest.id)
|
||||
virtual_count = sum(vp.referral_count for vp in virtual_list)
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
|
||||
lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
@@ -256,8 +262,9 @@ async def show_contest_details(
|
||||
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}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -427,7 +434,7 @@ async def show_leaderboard(
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
|
||||
return
|
||||
@@ -435,9 +442,9 @@ async def show_leaderboard(
|
||||
lines = [
|
||||
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
lines.append(f'{idx}. {user.full_name} ({user_id_display}) — {score}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -676,6 +683,9 @@ async def show_detailed_stats(
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
virtual_count = len(virtual)
|
||||
virtual_referrals = sum(vp.referral_count for vp in virtual)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
@@ -693,6 +703,10 @@ async def show_detailed_stats(
|
||||
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
|
||||
]
|
||||
|
||||
if virtual_count > 0:
|
||||
general_lines.append('')
|
||||
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
@@ -970,6 +984,274 @@ async def debug_contest_transactions(
|
||||
)
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_virtual_participants(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
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
|
||||
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for vp in vps:
|
||||
lines.append(f'• {vp.display_name} — {vp.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='➕ Добавить',
|
||||
callback_data=f'admin_contest_vp_add_{contest_id}',
|
||||
),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
for vp in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {vp.display_name}',
|
||||
callback_data=f'admin_contest_vp_edit_{vp.id}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='🗑',
|
||||
callback_data=f'admin_contest_vp_del_{vp.id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ Назад',
|
||||
callback_data=f'admin_contest_view_{contest_id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
await state.set_state(AdminStates.adding_virtual_participant_name)
|
||||
await state.update_data(vp_contest_id=contest_id)
|
||||
await callback.message.edit_text(
|
||||
'👻 Введите отображаемое имя виртуального участника:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_name(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
name = message.text.strip()
|
||||
if not name or len(name) > 200:
|
||||
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
|
||||
return
|
||||
await state.update_data(vp_name=name)
|
||||
await state.set_state(AdminStates.adding_virtual_participant_count)
|
||||
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data['vp_contest_id']
|
||||
display_name = data['vp_name']
|
||||
await state.clear()
|
||||
|
||||
vp = await add_virtual_participant(db, contest_id, display_name, count)
|
||||
await message.answer(
|
||||
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_virtual_participant_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получим contest_id до удаления
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
contest_id = vp.contest_id
|
||||
deleted = await delete_virtual_participant(db, vp_id)
|
||||
if deleted:
|
||||
await callback.answer('✅ Удалён', show_alert=False)
|
||||
else:
|
||||
await callback.answer('Не удалось удалить.', show_alert=True)
|
||||
|
||||
# Вернуться к списку
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for v in vps:
|
||||
lines.append(f'• {v.display_name} — {v.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
|
||||
]
|
||||
if vps:
|
||||
for v in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
|
||||
),
|
||||
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
|
||||
]
|
||||
)
|
||||
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.editing_virtual_participant_count)
|
||||
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>{vp.display_name}</b>\n'
|
||||
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
|
||||
f'Введите новое количество:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
vp_id = data['vp_edit_id']
|
||||
contest_id = data['vp_edit_contest_id']
|
||||
await state.clear()
|
||||
|
||||
vp = await update_virtual_participant_count(db, vp_id, count)
|
||||
if vp:
|
||||
await message.answer(
|
||||
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
else:
|
||||
await message.answer('Участник не найден.')
|
||||
|
||||
|
||||
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')
|
||||
@@ -996,3 +1278,11 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|
||||
|
||||
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
|
||||
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
|
||||
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
|
||||
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
|
||||
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
|
||||
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
|
||||
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
|
||||
|
||||
@@ -649,6 +649,12 @@ def get_referral_contest_manage_keyboard(
|
||||
callback_data=f'admin_contest_edit_times_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='👻 Виртуальные',
|
||||
callback_data=f'admin_contest_vp_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='🔄 Синхронизация',
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.models import (
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
@@ -109,6 +110,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return 'CloudPayments'
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return 'Freekassa'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -133,6 +136,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_cloudpayments_enabled()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.is_freekassa_enabled()
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.is_kassa_ai_enabled()
|
||||
return False
|
||||
|
||||
|
||||
@@ -356,6 +361,13 @@ def _is_freekassa_pending(payment: FreekassaPayment) -> bool:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _is_kassa_ai_pending(payment: KassaAiPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or '').lower()
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
|
||||
|
||||
def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int:
|
||||
payload = payment.payload or ''
|
||||
match = re.search(r'_(\d+)$', payload)
|
||||
@@ -648,6 +660,31 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_kassa_ai_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(KassaAiPayment)
|
||||
.options(selectinload(KassaAiPayment.user))
|
||||
.where(KassaAiPayment.created_at >= cutoff)
|
||||
.order_by(desc(KassaAiPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_kassa_ai_pending(payment):
|
||||
continue
|
||||
record = _build_record(
|
||||
PaymentMethod.KASSA_AI,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -694,6 +731,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_cloudpayments_payments(db, cutoff),
|
||||
await _fetch_freekassa_payments(db, cutoff),
|
||||
await _fetch_kassa_ai_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
)
|
||||
|
||||
@@ -848,6 +886,20 @@ async def get_payment_record(
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
payment = await db.get(KassaAiPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=['user'])
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
transaction = await db.get(Transaction, local_payment_id)
|
||||
if not transaction:
|
||||
|
||||
@@ -12,10 +12,11 @@ from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_contest_event,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contest_leaderboard_with_virtual,
|
||||
get_contests_for_events,
|
||||
get_contests_for_summaries,
|
||||
get_referrer_score,
|
||||
list_virtual_participants,
|
||||
mark_daily_summary_sent,
|
||||
mark_final_summary_sent,
|
||||
)
|
||||
@@ -173,8 +174,10 @@ class ReferralContestService:
|
||||
day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None)
|
||||
day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
leaderboard = list(await get_contest_leaderboard(db, contest.id))
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id)
|
||||
virtual_participants = await list_virtual_participants(db, contest.id)
|
||||
virtual_count = sum(vp.referral_count for vp in virtual_participants)
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
today_events = await get_contest_events_count(
|
||||
db,
|
||||
contest.id,
|
||||
@@ -269,7 +272,7 @@ class ReferralContestService:
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[tuple[User, int, int]],
|
||||
leaderboard: Sequence[tuple[str, int, int, bool]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
@@ -293,10 +296,9 @@ class ReferralContestService:
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
name = user.full_name
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
lines.append(f'{idx}. {name} ({user_id_display}) — {score}')
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
@@ -318,7 +320,7 @@ class ReferralContestService:
|
||||
self,
|
||||
*,
|
||||
contest: ReferralContest,
|
||||
leaderboard: Sequence[tuple[User, int, int]],
|
||||
leaderboard: Sequence[tuple[str, int, int, bool]],
|
||||
total_events: int,
|
||||
today_events: int,
|
||||
is_final: bool,
|
||||
@@ -346,8 +348,8 @@ class ReferralContestService:
|
||||
]
|
||||
|
||||
if leaderboard:
|
||||
for idx, (user, score, _) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f'{idx}. {user.full_name} — {score}')
|
||||
for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1):
|
||||
lines.append(f'{idx}. {name} — {score}')
|
||||
else:
|
||||
lines.append('Пока нет участников.')
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ class AdminStates(StatesGroup):
|
||||
creating_referral_contest_end = State()
|
||||
creating_referral_contest_time = State()
|
||||
editing_referral_contest_summary_times = State()
|
||||
adding_virtual_participant_name = State()
|
||||
adding_virtual_participant_count = State()
|
||||
editing_virtual_participant_count = State()
|
||||
editing_daily_contest_field = State()
|
||||
editing_daily_contest_value = State()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user