Merge pull request #2446 from Gy9vin/main

fix
This commit is contained in:
Mikhail
2026-01-28 13:55:37 +03:00
committed by GitHub
9 changed files with 737 additions and 154 deletions
+187 -134
View File
@@ -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
+94
View File
@@ -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
+18
View File
@@ -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'
+65
View File
@@ -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 готова')
+299 -9
View File
@@ -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)
+6
View File
@@ -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:
+13 -11
View File
@@ -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('Пока нет участников.')
+3
View File
@@ -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()