From 95b7152c05d625bcebbbe816b069c0e5982cdc0d Mon Sep 17 00:00:00 2001 From: gy9vin Date: Tue, 27 Jan 2026 23:47:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=81=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=87=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cabinet/routes/balance.py | 53 ++- app/database/crud/referral_contest.py | 98 +++++- app/database/models.py | 18 + app/database/universal_migration.py | 97 +++++- app/handlers/admin/contests.py | 308 +++++++++++++++++- app/keyboards/admin.py | 6 + app/services/payment_verification_service.py | 52 +++ app/services/referral_contest_service.py | 40 ++- app/states.py | 3 + app/utils/pricing_utils.py | 6 +- tests/services/test_kassa_ai_notifications.py | 26 +- 11 files changed, 648 insertions(+), 59 deletions(-) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 34306627..689d861b 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -284,6 +284,19 @@ async def get_payment_methods(): ) ) + # 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( @@ -728,6 +741,31 @@ async def create_topup( detail='Failed to create FreeKassa payment', ) + elif request.payment_method == 'kassa_ai': + if not settings.is_kassa_ai_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='KassaAI payment method is unavailable', + ) + + payment_service = PaymentService() + result = await payment_service.create_kassa_ai_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create KassaAI payment', + ) + elif request.payment_method == 'tribute': if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK: raise HTTPException( @@ -868,6 +906,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 '❓', 'Неизвестно' @@ -896,6 +945,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 @@ -919,7 +970,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 diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index d8278442..df35d676 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload from app.database.models import ( ReferralContest, ReferralContestEvent, + ReferralContestVirtualParticipant, Transaction, TransactionType, User, @@ -617,9 +618,7 @@ async def debug_contest_transactions( # Подсчёт общих сумм ПО ТИПАМ (исключаем бонусы без payment_method) deposit_in_period = sum( - tx.amount_kopeks - for tx in txs_in - if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None + tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None ) subscription_in_period = sum( tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value @@ -920,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 diff --git a/app/database/models.py b/app/database/models.py index dc85abc6..62ef1707 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1529,6 +1529,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"" + ) + + class ContestTemplate(Base): __tablename__ = 'contest_templates' diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 8598446e..b83c0288 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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']: @@ -4223,33 +4282,33 @@ async def fix_button_click_logs_fk() -> bool: logger.info('🔧 Исправляем FK button_click_logs.user_id: telegram_id -> id') # Обнуляем невалидные user_id (которые были internal id, а не telegram_id) - await conn.execute(text(""" + await conn.execute( + text(""" UPDATE button_click_logs SET user_id = NULL WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT telegram_id FROM users) - """)) + """) + ) # Удаляем старый FK - await conn.execute(text( - 'ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey' - )) + await conn.execute( + text('ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey') + ) # Меняем тип колонки и добавляем правильный FK - await conn.execute(text( - 'ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER' - )) + await conn.execute(text('ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER')) # Обнуляем все значения, т.к. они были записаны неправильно - await conn.execute(text( - 'UPDATE button_click_logs SET user_id = NULL' - )) + await conn.execute(text('UPDATE button_click_logs SET user_id = NULL')) - await conn.execute(text( - 'ALTER TABLE button_click_logs ' - 'ADD CONSTRAINT button_click_logs_user_id_fkey ' - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL' - )) + await conn.execute( + text( + 'ALTER TABLE button_click_logs ' + 'ADD CONSTRAINT button_click_logs_user_id_fkey ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL' + ) + ) logger.info('✅ FK button_click_logs.user_id исправлен') else: @@ -6395,6 +6454,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 готова') diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 4d8b326b..997cf902 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -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'🏆 {contest.title}', @@ -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' 📥 Пополнения баланса: {stats.get("deposit_total", 0) // 100} руб.', ] + if virtual_count > 0: + general_lines.append('') + general_lines.append(f'👻 Виртуальных: {virtual_count} (рефералов: {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'👻 Виртуальные участники — {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'Имя: {name}\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Имя: {vp.display_name}\nРефералов: {vp.referral_count}', + 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'👻 Виртуальные участники — {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'✏️ {vp.display_name}\n' + f'Текущее кол-во рефералов: {vp.referral_count}\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'✅ Обновлено: {vp.display_name} — {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) diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 07fa68d2..4a1a17d9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -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='🔄 Синхронизация', diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index b20f0a3f..0b432306 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -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: diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index eecf2612..c39d2354 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -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('Пока нет участников.') @@ -537,8 +539,12 @@ class ReferralContestService: for contest in contests: try: # Проверяем что реферал зарегистрировался В ПЕРИОД конкурса - user_created_at = user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None) - contest_start = contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None) + user_created_at = ( + user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None) + ) + contest_start = ( + contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None) + ) contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None) if user_created_at < contest_start or user_created_at > contest_end: @@ -594,8 +600,12 @@ class ReferralContestService: for contest in contests: try: # Проверяем что реферал зарегистрировался В ПЕРИОД конкурса - user_created_at = user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None) - contest_start = contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None) + user_created_at = ( + user.created_at if user.created_at.tzinfo is None else user.created_at.replace(tzinfo=None) + ) + contest_start = ( + contest.start_at if contest.start_at.tzinfo is None else contest.start_at.replace(tzinfo=None) + ) contest_end = contest.end_at if contest.end_at.tzinfo is None else contest.end_at.replace(tzinfo=None) if user_created_at < contest_start or user_created_at > contest_end: diff --git a/app/states.py b/app/states.py index c567f40e..351880d0 100644 --- a/app/states.py +++ b/app/states.py @@ -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() diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 82189874..d585fee1 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -256,7 +256,11 @@ async def compute_simple_subscription_price( ) total_before_discount = ( - base_price_original + traffic_price_original + devices_price_original + servers_price_original + modem_price_original + base_price_original + + traffic_price_original + + devices_price_original + + servers_price_original + + modem_price_original ) total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total diff --git a/tests/services/test_kassa_ai_notifications.py b/tests/services/test_kassa_ai_notifications.py index e585ce2c..3912a2e1 100644 --- a/tests/services/test_kassa_ai_notifications.py +++ b/tests/services/test_kassa_ai_notifications.py @@ -2,20 +2,18 @@ Упрощенные тесты для проверки логики уведомлений Kassa AI. """ -from unittest.mock import AsyncMock, MagicMock, patch -import pytest +from unittest.mock import MagicMock def test_notification_message_bright_prompt(): """ Тест: проверяем что формируется ЯРКОЕ сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true. """ - from app.config import settings # Эмулируем код из kassa_ai.py SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = True - display_name = "Kassa AI" - amount_formatted = "10₽" + display_name = 'Kassa AI' + amount_formatted = '10₽' if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: message = ( @@ -37,7 +35,7 @@ def test_notification_message_bright_prompt(): assert '👇' in message assert display_name in message assert amount_formatted in message - print(f"\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}") + print(f'\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}') def test_notification_message_standard(): @@ -46,8 +44,8 @@ def test_notification_message_standard(): """ # Эмулируем код из kassa_ai.py SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = False - display_name = "Kassa AI" - amount_formatted = "10₽" + display_name = 'Kassa AI' + amount_formatted = '10₽' if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: message = '' @@ -69,7 +67,7 @@ def test_notification_message_standard(): assert 'Платеж успешно завершен' in message assert display_name in message assert amount_formatted in message - print(f"\n✅ Обычное сообщение сформировано правильно:\n{message}") + print(f'\n✅ Обычное сообщение сформировано правильно:\n{message}') def test_telegram_id_saved_before_commit(): @@ -92,7 +90,7 @@ def test_telegram_id_saved_before_commit(): # Проверяем что локальные переменные сохранились assert user_telegram_id == 123456789 assert user_language == 'ru' - print(f"\n✅ telegram_id сохранен в локальную переменную: {user_telegram_id}") + print(f'\n✅ telegram_id сохранен в локальную переменную: {user_telegram_id}') def test_send_message_called_with_correct_params(): @@ -103,7 +101,7 @@ def test_send_message_called_with_correct_params(): bot.send_message = MagicMock() user_telegram_id = 123456789 - message = "Тестовое сообщение" + message = 'Тестовое сообщение' keyboard = MagicMock() # Эмулируем вызов @@ -121,7 +119,7 @@ def test_send_message_called_with_correct_params(): assert call_args[1]['chat_id'] == 123456789 assert call_args[1]['parse_mode'] == 'HTML' assert call_args[1]['text'] == message - print(f"\n✅ bot.send_message вызван с правильными параметрами") + print('\n✅ bot.send_message вызван с правильными параметрами') def test_no_send_when_no_telegram_id(): @@ -135,8 +133,8 @@ def test_no_send_when_no_telegram_id(): # Эмулируем проверку if bot and user_telegram_id: - bot.send_message(chat_id=user_telegram_id, text="test") + bot.send_message(chat_id=user_telegram_id, text='test') # Проверка bot.send_message.assert_not_called() - print(f"\n✅ bot.send_message НЕ вызван когда telegram_id=None") + print('\n✅ bot.send_message НЕ вызван когда telegram_id=None')