From 427011fe419977d79cbd31b372323cb71718c96b Mon Sep 17 00:00:00 2001 From: Pavel Stryuk Date: Tue, 4 Nov 2025 13:05:02 +0100 Subject: [PATCH] =?UTF-8?q?1)=20=D0=9E=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=D1=85=20?= =?UTF-8?q?(=D0=BA=D1=80=D0=B0=D1=81=D0=B8=D0=B2=D0=BE=D0=B5!)=202)=20?= =?UTF-8?q?=D0=A3=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=20=D0=BF=D0=BE=D1=8F=D0=B2=D0=B8=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=203)=20?= =?UTF-8?q?=D0=A3=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B6=D0=B5=D1=82=20=D0=B1?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF,=20=D0=BD=D0=BE=20=D0=B2=D0=BB=D0=B8=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=81=20=D0=BD=D0=B0=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=D1=81=D1=88=D0=B8=D0=BC=20=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=82=D0=BE=D0=BC=204)=20=D0=9A=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=B0=D0=BC=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=D0=BE=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=83.=20=D0=92?= =?UTF-8?q?=D1=81=D0=B5=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=B2=D1=88=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D1=82=20=D0=B5=D1=91=205)=20=D0=9F=D1=80=D0=B8=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D1=81=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=D0=BE=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D1=82=D0=B0=D0=BA?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=D1=81=D1=8F=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=B3=D0=BE.=20=D0=9C?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BE=D1=82=D1=81=D0=BB=D0=B5=D0=B4=D0=B8=D1=82=D1=8C=20=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D0=B2=D1=8B=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2=20"=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B8=D1=85".=20=D0=AF=20=D0=B2=20=D1=86=D0=B5=D0=BB?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=8D=D1=82=D0=BE=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D1=8E=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0,=20=D0=B3=D0=B4=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5=206)=20=D0=98?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B3=20=D0=B8=D1=81=D1=87=D0=B5=D0=B7=D0=BD=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=82=D1=80=D0=B8=D0=B0=D0=BB=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=207)=20=D0=98=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=89?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=208)=20=D0=A2=D1=80=D0=B0=D1=84=D0=B8=D0=BA:=200?= =?UTF-8?q?=20=D0=93=D0=91=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B5?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=A2=D1=80=D0=B0=D1=84=D0=B8=D0=BA:=20=D0=91?= =?UTF-8?q?=D0=B5=D0=B7=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 12 + app/database/crud/promo_group.py | 7 +- app/database/crud/promocode.py | 31 +- app/database/crud/subscription.py | 50 +- app/database/crud/user.py | 11 +- app/database/crud/user_promo_group.py | 260 ++++++++ app/database/models.py | 63 +- app/database/universal_migration.py | 241 ++++++++ app/handlers/admin/promo_groups.py | 105 +++- app/handlers/admin/promocodes.py | 145 ++++- app/handlers/admin/servers.py | 3 +- app/handlers/admin/users.py | 203 ++++-- app/keyboards/admin.py | 18 +- app/keyboards/inline.py | 20 +- app/localization/locales/en.json | 14 +- app/localization/locales/ru.json | 12 + app/localization/texts.py | 46 +- app/services/payment/yookassa.py | 8 +- app/services/promo_group_assignment.py | 31 +- app/services/promocode_service.py | 46 ++ .../subscription_auto_purchase_service.py | 25 +- app/services/subscription_service.py | 8 +- app/states.py | 3 + app/utils/pricing_utils.py | 34 +- tests/conftest.py | 5 + tests/crud/__init__.py | 0 tests/crud/test_promocode_crud.py | 142 +++++ tests/fixtures/__init__.py | 1 + tests/fixtures/promocode_fixtures.py | 206 ++++++ tests/integration/__init__.py | 0 .../test_promocode_promo_group_flow.py | 338 ++++++++++ tests/services/test_promocode_service.py | 584 ++++++++++++++++++ tests/services/test_referral_service.py | 1 - tests/services/test_remnawave_service_sync.py | 2 - ...test_subscription_auto_purchase_service.py | 378 +++++++++++- .../test_system_settings_env_priority.py | 4 - tests/test_subscription_cart_integration.py | 7 - tests/test_user_cart_service.py | 7 - tests/utils/test_pricing_utils.py | 405 ++++++++++++ 39 files changed, 3263 insertions(+), 213 deletions(-) create mode 100644 Makefile create mode 100644 app/database/crud/user_promo_group.py create mode 100644 tests/crud/__init__.py create mode 100644 tests/crud/test_promocode_crud.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/promocode_fixtures.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_promocode_promo_group_flow.py create mode 100644 tests/services/test_promocode_service.py create mode 100644 tests/utils/test_pricing_utils.py diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..3210d9de --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: up down reload test + +up: + docker compose up -d + +down: + docker compose down + +reload: down up + +test: + pytest diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 102d2a0b..b2b02dd7 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -38,7 +38,7 @@ async def get_promo_groups_with_counts( select(PromoGroup, func.count(User.id)) .outerjoin(User, User.promo_group_id == PromoGroup.id) .group_by(PromoGroup.id) - .order_by(PromoGroup.is_default.desc(), PromoGroup.name) + .order_by(PromoGroup.priority.desc(), PromoGroup.name) ) if offset: @@ -89,6 +89,7 @@ async def create_promo_group( db: AsyncSession, name: str, *, + priority: int = 0, server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, @@ -110,6 +111,7 @@ async def create_promo_group( promo_group = PromoGroup( name=name.strip(), + priority=max(0, priority), server_discount_percent=max(0, min(100, server_discount_percent)), traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), @@ -152,6 +154,7 @@ async def update_promo_group( group: PromoGroup, *, name: Optional[str] = None, + priority: Optional[int] = None, server_discount_percent: Optional[int] = None, traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, @@ -162,6 +165,8 @@ async def update_promo_group( ) -> PromoGroup: if name is not None: group.name = name.strip() + if priority is not None: + group.priority = max(0, priority) if server_discount_percent is not None: group.server_discount_percent = max(0, min(100, server_discount_percent)) if traffic_discount_percent is not None: diff --git a/app/database/crud/promocode.py b/app/database/crud/promocode.py index 67b9bc37..cbe3c3a0 100644 --- a/app/database/crud/promocode.py +++ b/app/database/crud/promocode.py @@ -13,7 +13,10 @@ logger = logging.getLogger(__name__) async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]: result = await db.execute( select(PromoCode) - .options(selectinload(PromoCode.uses)) + .options( + selectinload(PromoCode.uses), + selectinload(PromoCode.promo_group) + ) .where(PromoCode.code == code.upper()) ) return result.scalar_one_or_none() @@ -27,7 +30,8 @@ async def create_promocode( subscription_days: int = 0, max_uses: int = 1, valid_until: Optional[datetime] = None, - created_by: Optional[int] = None + created_by: Optional[int] = None, + promo_group_id: Optional[int] = None ) -> PromoCode: promocode = PromoCode( @@ -37,14 +41,18 @@ async def create_promocode( subscription_days=subscription_days, max_uses=max_uses, valid_until=valid_until, - created_by=created_by + created_by=created_by, + promo_group_id=promo_group_id ) db.add(promocode) await db.commit() await db.refresh(promocode) - - logger.info(f"✅ Создан промокод: {code}") + + if promo_group_id: + logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}") + else: + logger.info(f"✅ Создан промокод: {code}") return promocode @@ -143,14 +151,17 @@ async def get_promocodes_list( limit: int = 50, is_active: Optional[bool] = None ) -> List[PromoCode]: - - query = select(PromoCode).options(selectinload(PromoCode.uses)) - + + query = select(PromoCode).options( + selectinload(PromoCode.uses), + selectinload(PromoCode.promo_group) + ) + if is_active is not None: query = query.where(PromoCode.is_active == is_active) - + query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit) - + result = await db.execute(query) return result.scalars().all() diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index c767869e..158bed1e 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -11,6 +11,7 @@ from app.database.models import ( User, SubscriptionServer, PromoGroup, + UserPromoGroup, ) from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months @@ -195,10 +196,27 @@ async def extend_subscription( days: int ) -> Subscription: current_time = datetime.utcnow() - + logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}") - + + # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date + bonus_days = 0 + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + # Вычисляем остаток триала + if subscription.end_date and subscription.end_date > current_time: + remaining = subscription.end_date - current_time + if remaining.total_seconds() > 0: + bonus_days = max(0, remaining.days) + logger.info( + "🎁 Обнаружен остаток триала: %s дней для подписки %s", + bonus_days, + subscription.id, + ) + + # Применяем продление с учетом бонусных дней + total_days = days + bonus_days + if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) logger.info( @@ -207,27 +225,15 @@ async def extend_subscription( subscription.end_date, ) elif subscription.end_date > current_time: - subscription.end_date = subscription.end_date + timedelta(days=days) - logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") + subscription.end_date = subscription.end_date + timedelta(days=total_days) + logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") else: - subscription.end_date = current_time + timedelta(days=days) - logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания") + subscription.end_date = current_time + timedelta(days=total_days) + logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)") - if subscription.is_trial: - start_date = subscription.start_date or current_time - total_duration = subscription.end_date - start_date - max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS) - - if total_duration > max_trial_duration: - subscription.is_trial = False - logger.info( - "🎯 Подписка %s автоматически переведена из триальной в платную после продления" - ", итоговая длительность: %s дней", - subscription.id, - total_duration.days, - ) - if subscription.user: - subscription.user.has_had_paid_subscription = True + # УДАЛЕНО: Автоматическая конвертация триала по длительности + # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления + # и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription) # Логируем статус подписки перед проверкой logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}") @@ -915,7 +921,7 @@ async def get_subscription_renewal_cost( result = await db.execute( select(Subscription) .options( - selectinload(Subscription.user).selectinload(User.promo_group), + selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), ) .where(Subscription.id == subscription_id) ) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 8ce68101..d5a42109 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -15,6 +15,7 @@ from app.database.models import ( SubscriptionStatus, Transaction, PromoGroup, + UserPromoGroup, PaymentMethod, TransactionType, ) @@ -38,7 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: select(User) .options( selectinload(User.subscription), - selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), ) .where(User.id == user_id) @@ -56,7 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona select(User) .options( selectinload(User.subscription), - selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), ) .where(User.telegram_id == telegram_id) @@ -79,7 +80,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User select(User) .options( selectinload(User.subscription), - selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), ) .where(func.lower(User.username) == normalized) @@ -749,7 +750,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: select(User) .options( selectinload(User.subscription), - selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), ) .where(User.referred_by_id == user_id) .order_by(User.created_at.desc()) @@ -817,7 +818,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]: select(User) .options( selectinload(User.subscription), - selectinload(User.promo_group), + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), ) .where( and_( diff --git a/app/database/crud/user_promo_group.py b/app/database/crud/user_promo_group.py new file mode 100644 index 00000000..bf379bca --- /dev/null +++ b/app/database/crud/user_promo_group.py @@ -0,0 +1,260 @@ +"""CRUD операции для связи пользователей с промогруппами (Many-to-Many).""" +import logging +from typing import List, Optional + +from sqlalchemy import select, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import UserPromoGroup, PromoGroup, User + +logger = logging.getLogger(__name__) + + +async def add_user_to_promo_group( + db: AsyncSession, + user_id: int, + promo_group_id: int, + assigned_by: str = "admin" +) -> Optional[UserPromoGroup]: + """ + Добавляет пользователю промогруппу. + + Args: + db: Сессия БД + user_id: ID пользователя + promo_group_id: ID промогруппы + assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode') + + Returns: + UserPromoGroup или None если уже существует + """ + try: + # Проверяем существование связи + existing = await has_user_promo_group(db, user_id, promo_group_id) + if existing: + logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}") + return None + + # Создаем новую связь + user_promo_group = UserPromoGroup( + user_id=user_id, + promo_group_id=promo_group_id, + assigned_by=assigned_by + ) + db.add(user_promo_group) + await db.commit() + await db.refresh(user_promo_group) + + logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})") + return user_promo_group + + except Exception as error: + logger.error(f"Ошибка добавления промогруппы пользователю: {error}") + await db.rollback() + return None + + +async def remove_user_from_promo_group( + db: AsyncSession, + user_id: int, + promo_group_id: int +) -> bool: + """ + Удаляет промогруппу у пользователя. + + Args: + db: Сессия БД + user_id: ID пользователя + promo_group_id: ID промогруппы + + Returns: + True если удалено, False если связи не было + """ + try: + result = await db.execute( + select(UserPromoGroup).where( + and_( + UserPromoGroup.user_id == user_id, + UserPromoGroup.promo_group_id == promo_group_id + ) + ) + ) + user_promo_group = result.scalar_one_or_none() + + if not user_promo_group: + logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена") + return False + + await db.delete(user_promo_group) + await db.commit() + + logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}") + return True + + except Exception as error: + logger.error(f"Ошибка удаления промогруппы у пользователя: {error}") + await db.rollback() + return False + + +async def get_user_promo_groups( + db: AsyncSession, + user_id: int +) -> List[UserPromoGroup]: + """ + Получает все промогруппы пользователя, отсортированные по приоритету. + + Args: + db: Сессия БД + user_id: ID пользователя + + Returns: + Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC + """ + try: + result = await db.execute( + select(UserPromoGroup) + .options(selectinload(UserPromoGroup.promo_group)) + .where(UserPromoGroup.user_id == user_id) + .join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id) + .order_by(desc(PromoGroup.priority), PromoGroup.id) + ) + return list(result.scalars().all()) + + except Exception as error: + logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}") + return [] + + +async def get_primary_user_promo_group( + db: AsyncSession, + user_id: int +) -> Optional[PromoGroup]: + """ + Получает промогруппу пользователя с максимальным приоритетом. + + Args: + db: Сессия БД + user_id: ID пользователя + + Returns: + PromoGroup с максимальным приоритетом или None + """ + try: + user_promo_groups = await get_user_promo_groups(db, user_id) + + if not user_promo_groups: + return None + + # Первая в списке имеет максимальный приоритет (список уже отсортирован) + return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None + + except Exception as error: + logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}") + return None + + +async def has_user_promo_group( + db: AsyncSession, + user_id: int, + promo_group_id: int +) -> bool: + """ + Проверяет наличие промогруппы у пользователя. + + Args: + db: Сессия БД + user_id: ID пользователя + promo_group_id: ID промогруппы + + Returns: + True если пользователь уже имеет эту промогруппу + """ + try: + result = await db.execute( + select(UserPromoGroup).where( + and_( + UserPromoGroup.user_id == user_id, + UserPromoGroup.promo_group_id == promo_group_id + ) + ) + ) + return result.scalar_one_or_none() is not None + + except Exception as error: + logger.error(f"Ошибка проверки промогруппы пользователя: {error}") + return False + + +async def count_user_promo_groups( + db: AsyncSession, + user_id: int +) -> int: + """ + Подсчитывает количество промогрупп у пользователя. + + Args: + db: Сессия БД + user_id: ID пользователя + + Returns: + Количество промогрупп + """ + try: + result = await db.execute( + select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) + ) + return len(list(result.scalars().all())) + + except Exception as error: + logger.error(f"Ошибка подсчета промогрупп пользователя: {error}") + return 0 + + +async def replace_user_promo_groups( + db: AsyncSession, + user_id: int, + promo_group_ids: List[int], + assigned_by: str = "admin" +) -> bool: + """ + Заменяет все промогруппы пользователя на новый список. + + Args: + db: Сессия БД + user_id: ID пользователя + promo_group_ids: Список ID промогрупп + assigned_by: Кто назначил + + Returns: + True если успешно + """ + try: + # Удаляем все текущие промогруппы + await db.execute( + select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) + ) + result = await db.execute( + select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) + ) + for upg in result.scalars().all(): + await db.delete(upg) + + # Добавляем новые + for promo_group_id in promo_group_ids: + user_promo_group = UserPromoGroup( + user_id=user_id, + promo_group_id=promo_group_id, + assigned_by=assigned_by + ) + db.add(user_promo_group) + + await db.commit() + logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}") + return True + + except Exception as error: + logger.error(f"Ошибка замены промогрупп пользователя: {error}") + await db.rollback() + return False diff --git a/app/database/models.py b/app/database/models.py index b4e9bc80..c1f733aa 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -67,9 +67,10 @@ class TransactionType(Enum): class PromoCodeType(Enum): - BALANCE = "balance" - SUBSCRIPTION_DAYS = "subscription_days" - TRIAL_SUBSCRIPTION = "trial_subscription" + BALANCE = "balance" + SUBSCRIPTION_DAYS = "subscription_days" + TRIAL_SUBSCRIPTION = "trial_subscription" + PROMO_GROUP = "promo_group" class PaymentMethod(Enum): @@ -418,6 +419,7 @@ class PromoGroup(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(255), unique=True, nullable=False) + priority = Column(Integer, nullable=False, default=0, index=True) server_discount_percent = Column(Integer, nullable=False, default=0) traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) @@ -429,6 +431,7 @@ class PromoGroup(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) users = relationship("User", back_populates="promo_group") + user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan") server_squads = relationship( "ServerSquad", secondary=server_squad_promo_groups, @@ -492,6 +495,22 @@ class PromoGroup(Base): return max(0, min(100, percent)) +class UserPromoGroup(Base): + """Таблица связи Many-to-Many между пользователями и промогруппами.""" + __tablename__ = "user_promo_groups" + + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True) + assigned_at = Column(DateTime, default=func.now()) + assigned_by = Column(String(50), default="system") + + user = relationship("User", back_populates="user_promo_groups") + promo_group = relationship("PromoGroup", back_populates="user_promo_groups") + + def __repr__(self): + return f"" + + class User(Base): __tablename__ = "users" @@ -529,23 +548,43 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) + promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True) promo_group = relationship("PromoGroup", back_populates="users") + user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan") poll_responses = relationship("PollResponse", back_populates="user") - + @property def balance_rubles(self) -> float: return self.balance_kopeks / 100 - + @property def full_name(self) -> str: parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" + def get_primary_promo_group(self): + """Возвращает промогруппу с максимальным приоритетом.""" + if not self.user_promo_groups: + return None + + # Сортируем по приоритету группы (убывание), затем по ID группы + sorted_groups = sorted( + self.user_promo_groups, + key=lambda upg: (upg.promo_group.priority if upg.promo_group else 0, upg.promo_group_id), + reverse=True + ) + + if sorted_groups and sorted_groups[0].promo_group: + return sorted_groups[0].promo_group + + # Fallback на старую связь если новая пустая + return self.promo_group + def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: - if not self.promo_group: + primary_group = self.get_primary_promo_group() + if not primary_group: return 0 - return self.promo_group.get_discount_percent(category, period_days) + return primary_group.get_discount_percent(category, period_days) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks @@ -793,13 +832,15 @@ class PromoCode(Base): valid_until = Column(DateTime, nullable=True) is_active = Column(Boolean, default=True) - + created_by = Column(Integer, ForeignKey("users.id"), nullable=True) - + promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + uses = relationship("PromoCodeUse", back_populates="promocode") + promo_group = relationship("PromoGroup") @property def is_valid(self) -> bool: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 0e140442..595d9635 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -3459,6 +3459,219 @@ async def ensure_default_web_api_token() -> bool: return False +async def add_promo_group_priority_column() -> bool: + """Добавляет колонку priority в таблицу promo_groups.""" + column_exists = await check_column_exists('promo_groups', 'priority') + if column_exists: + logger.info("Колонка priority уже существует в promo_groups") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'INTEGER NOT NULL DEFAULT 0' + elif db_type == 'postgresql': + column_def = 'INTEGER NOT NULL DEFAULT 0' + else: + column_def = 'INT NOT NULL DEFAULT 0' + + await conn.execute( + text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}") + ) + + # Создаем индекс для оптимизации сортировки + if db_type == 'postgresql': + await conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)") + ) + elif db_type == 'sqlite': + await conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)") + ) + else: # MySQL + await conn.execute( + text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)") + ) + + logger.info("✅ Добавлена колонка priority в promo_groups с индексом") + return True + + except Exception as error: + logger.error(f"Ошибка добавления колонки priority: {error}") + return False + + +async def create_user_promo_groups_table() -> bool: + """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" + table_exists = await check_table_exists("user_promo_groups") + if table_exists: + logger.info("ℹ️ Таблица user_promo_groups уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + create_sql = """ + CREATE TABLE user_promo_groups ( + user_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50) DEFAULT 'system', + PRIMARY KEY (user_id, promo_group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + """ + index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" + elif db_type == "postgresql": + create_sql = """ + CREATE TABLE user_promo_groups ( + user_id INTEGER NOT NULL, + promo_group_id INTEGER NOT NULL, + assigned_at TIMESTAMP DEFAULT NOW(), + assigned_by VARCHAR(50) DEFAULT 'system', + PRIMARY KEY (user_id, promo_group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + """ + index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" + else: # MySQL + create_sql = """ + CREATE TABLE user_promo_groups ( + user_id INT NOT NULL, + promo_group_id INT NOT NULL, + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50) DEFAULT 'system', + PRIMARY KEY (user_id, promo_group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE + ); + """ + index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" + + await conn.execute(text(create_sql)) + await conn.execute(text(index_sql)) + logger.info("✅ Таблица user_promo_groups создана с индексом") + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}") + return False + + +async def migrate_existing_user_promo_groups_data() -> bool: + """Переносит существующие связи users.promo_group_id в таблицу user_promo_groups.""" + try: + table_exists = await check_table_exists("user_promo_groups") + if not table_exists: + logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных") + return False + + column_exists = await check_column_exists('users', 'promo_group_id') + if not column_exists: + logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных") + return True + + async with engine.begin() as conn: + # Проверяем есть ли уже данные в user_promo_groups + result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups")) + count = result.scalar() + + if count > 0: + logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию") + return True + + # Переносим данные из users.promo_group_id + db_type = await get_database_type() + + if db_type == "sqlite": + migrate_sql = """ + INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by) + SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system' + FROM users + WHERE promo_group_id IS NOT NULL + """ + else: # PostgreSQL and MySQL + migrate_sql = """ + INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by) + SELECT id, promo_group_id, NOW(), 'system' + FROM users + WHERE promo_group_id IS NOT NULL + """ + + result = await conn.execute(text(migrate_sql)) + migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0 + + logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами") + return True + + except Exception as error: + logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}") + return False + + +async def add_promocode_promo_group_column() -> bool: + """Добавляет колонку promo_group_id в таблицу promocodes.""" + column_exists = await check_column_exists('promocodes', 'promo_group_id') + if column_exists: + logger.info("Колонка promo_group_id уже существует в promocodes") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + # Add column + if db_type == 'sqlite': + await conn.execute( + text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER") + ) + elif db_type == 'postgresql': + await conn.execute( + text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER") + ) + # Add foreign key + await conn.execute( + text(""" + ALTER TABLE promocodes + ADD CONSTRAINT fk_promocodes_promo_group + FOREIGN KEY (promo_group_id) + REFERENCES promo_groups(id) + ON DELETE SET NULL + """) + ) + # Add index + await conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)") + ) + elif db_type == 'mysql': + await conn.execute( + text(""" + ALTER TABLE promocodes + ADD COLUMN promo_group_id INT, + ADD CONSTRAINT fk_promocodes_promo_group + FOREIGN KEY (promo_group_id) + REFERENCES promo_groups(id) + ON DELETE SET NULL + """) + ) + await conn.execute( + text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)") + ) + + logger.info("✅ Добавлена колонка promo_group_id в promocodes") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -3620,6 +3833,34 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей promo_offer_templates") + logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===") + priority_column_ready = await add_promo_group_priority_column() + if priority_column_ready: + logger.info("✅ Колонка priority в promo_groups готова") + else: + logger.warning("⚠️ Проблемы с добавлением priority в promo_groups") + + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===") + user_promo_groups_ready = await create_user_promo_groups_table() + if user_promo_groups_ready: + logger.info("✅ Таблица user_promo_groups готова") + else: + logger.warning("⚠️ Проблемы с таблицей user_promo_groups") + + logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===") + data_migrated = await migrate_existing_user_promo_groups_data() + if data_migrated: + logger.info("✅ Данные перенесены в user_promo_groups") + else: + logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups") + + logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===") + promocode_column_ready = await add_promocode_promo_group_column() + if promocode_column_ready: + logger.info("✅ Колонка promo_group_id в promocodes готова") + else: + logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===") main_menu_buttons_created = await create_main_menu_buttons_table() if main_menu_buttons_created: diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 1cb1df73..b44c3409 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str: return formatted.replace(",", " ") +def _format_priority_line(texts, group: PromoGroup) -> str: + priority = getattr(group, "priority", 0) + return texts.t( + "ADMIN_PROMO_GROUP_PRIORITY_LINE", + "🎯 Приоритет: {priority}", + ).format(priority=priority) + + def _format_auto_assign_line(texts, group: PromoGroup) -> str: threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 @@ -294,6 +302,7 @@ def _build_edit_menu_content( lines = [header] lines.extend(_format_discount_lines(texts, group)) lines.append(_format_addon_discounts_line(texts, group)) + lines.append(_format_priority_line(texts, group)) lines.append(_format_auto_assign_line(texts, group)) period_lines = _format_period_discounts_lines(texts, group, language) @@ -318,6 +327,15 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_name", ) ], + [ + types.InlineKeyboardButton( + text=texts.t( + "ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY", + "🎯 Приоритет", + ), + callback_data=f"promo_group_edit_field_{group.id}_priority", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext): return await state.update_data(new_group_name=name) + await state.set_state(AdminStates.creating_promo_group_priority) + texts = get_texts((await state.get_data()).get("language", "ru")) + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT", + "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):", + ) + ) + + +async def process_create_group_priority(message: types.Message, state: FSMContext): + texts = get_texts((await state.get_data()).get("language", "ru")) + try: + priority = int(message.text) + if priority < 0: + raise ValueError + except (ValueError, TypeError): + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PRIORITY", + "❌ Приоритет должен быть неотрицательным целым числом", + ) + ) + return + + await state.update_data(new_group_priority=priority) await state.set_state(AdminStates.creating_promo_group_traffic_discount) await _prompt_for_discount( message, @@ -772,6 +816,7 @@ async def process_create_group_auto_assign( group = await create_promo_group( db, data["new_group_name"], + priority=data.get("new_group_priority", 0), traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], @@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field( "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", "Введите новое название промогруппы (текущее: {name}):", ).format(name=group.name) + elif field == "priority": + await state.set_state(AdminStates.editing_promo_group_priority) + prompt = texts.t( + "ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT", + "Введите новый приоритет (текущий: {current}):", + ).format(current=getattr(group, "priority", 0)) elif field == "traffic": await state.set_state(AdminStates.editing_promo_group_traffic_discount) prompt = texts.t( @@ -935,6 +986,48 @@ async def process_edit_group_name( ) +@admin_required +@error_handler +async def process_edit_group_priority( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + priority = int(message.text) + if priority < 0: + raise ValueError + except (ValueError, TypeError): + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_PRIORITY", + "❌ Приоритет должен быть неотрицательным целым числом", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + + group = await update_promo_group(db, group, priority=priority) + await state.set_state(AdminStates.editing_promo_group_menu) + + await _send_edit_menu_after_update( + message, + texts, + group, + data.get("language", db_user.language), + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + ) + + @admin_required @error_handler async def process_edit_group_traffic( @@ -1158,8 +1251,9 @@ async def show_promo_group_members( lines = [] for index, user in enumerate(members, start=offset + 1): username = f"@{user.username}" if user.username else "—" + user_link = f'{user.full_name}' lines.append( - f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})" + f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})" ) body = "\n".join(lines) @@ -1181,6 +1275,7 @@ async def show_promo_group_members( await callback.message.edit_text( f"{title}\n\n{body}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML", ) await callback.answer() @@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher): ) dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name) + dp.message.register( + process_create_group_priority, + AdminStates.creating_promo_group_priority, + ) dp.message.register( process_create_group_traffic, AdminStates.creating_promo_group_traffic_discount, @@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher): ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) + dp.message.register( + process_edit_group_priority, + AdminStates.editing_promo_group_priority, + ) dp.message.register( process_edit_group_traffic, AdminStates.editing_promo_group_traffic_discount, diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index e51d364b..e6d0e7aa 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -17,6 +17,7 @@ from app.database.crud.promocode import ( get_promocode_statistics, get_promocode_by_code, update_promocode, delete_promocode ) +from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime @@ -81,16 +82,24 @@ async def show_promocodes_list( for promo in promocodes: status_emoji = "✅" if promo.is_active else "❌" - type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") - + type_emoji = { + "balance": "💰", + "subscription_days": "📅", + "trial_subscription": "🎁", + "promo_group": "🏷️" + }.get(promo.type, "🎫") + text += f"{status_emoji} {type_emoji} {promo.code}\n" text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n" - + if promo.type == PromoCodeType.BALANCE.value: text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: text += f"📅 Дней: {promo.subscription_days}\n" - + elif promo.type == PromoCodeType.PROMO_GROUP.value: + if promo.promo_group: + text += f"🏷️ Промогруппа: {promo.promo_group.name}\n" + if promo.valid_until: text += f"⏰ До: {format_datetime(promo.valid_until)}\n" @@ -136,8 +145,13 @@ async def show_promocode_management( return status_emoji = "✅" if promo.is_active else "❌" - type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") - + type_emoji = { + "balance": "💰", + "subscription_days": "📅", + "trial_subscription": "🎁", + "promo_group": "🏷️" + }.get(promo.type, "🎫") + text = f""" 🎫 Управление промокодом @@ -145,12 +159,17 @@ async def show_promocode_management( {status_emoji} Статус: {'Активен' if promo.is_active else 'Неактивен'} 📊 Использований: {promo.current_uses}/{promo.max_uses} """ - + if promo.type == PromoCodeType.BALANCE.value: text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: text += f"📅 Дней: {promo.subscription_days}\n" - + elif promo.type == PromoCodeType.PROMO_GROUP.value: + if promo.promo_group: + text += f"🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n" + elif promo.promo_group_id: + text += f"🏷️ Промогруппа ID: {promo.promo_group_id} (не найдена)\n" + if promo.valid_until: text += f"⏰ Действует до: {format_datetime(promo.valid_until)}\n" @@ -445,13 +464,14 @@ async def select_promocode_type( state: FSMContext ): promo_type = callback.data.split('_')[-1] - + type_names = { "balance": "💰 Пополнение баланса", - "days": "📅 Дни подписки", - "trial": "🎁 Тестовая подписка" + "days": "📅 Дни подписки", + "trial": "🎁 Тестовая подписка", + "group": "🏷️ Промогруппа" } - + await state.update_data(promocode_type=promo_type) await callback.message.edit_text( @@ -509,6 +529,77 @@ async def process_promocode_code( f"Введите количество дней тестовой подписки:" ) await state.set_state(AdminStates.setting_promocode_value) + elif promo_type == "group": + # Show promo group selection + groups_with_counts = await get_promo_groups_with_counts(db, limit=50) + + if not groups_with_counts: + await message.answer( + "❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] + ]) + ) + await state.clear() + return + + keyboard = [] + text = f"🏷️ Промокод: {code}\n\nВыберите промогруппу для назначения:\n\n" + + for promo_group, user_count in groups_with_counts: + text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n" + keyboard.append([ + types.InlineKeyboardButton( + text=f"{promo_group.name} (↑{promo_group.priority})", + callback_data=f"promo_select_group_{promo_group.id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes") + ]) + + await message.answer( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await state.set_state(AdminStates.selecting_promo_group) + + +@admin_required +@error_handler +async def process_promo_group_selection( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession +): + """Handle promo group selection for promocode""" + try: + promo_group_id = int(callback.data.split('_')[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True) + return + + promo_group = await get_promo_group_by_id(db, promo_group_id) + if not promo_group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return + + await state.update_data( + promo_group_id=promo_group_id, + promo_group_name=promo_group.name + ) + + await callback.message.edit_text( + f"🏷️ Промокод для промогруппы\n\n" + f"Промогруппа: {promo_group.name}\n" + f"Приоритет: {promo_group.priority}\n\n" + f"📊 Введите количество использований промокода (или 0 для безлимита):" + ) + + await state.set_state(AdminStates.setting_promocode_uses) + await callback.answer() @admin_required @@ -708,17 +799,20 @@ async def process_promocode_expiry( promo_type = data.get('promocode_type') value = data.get('promocode_value', 0) max_uses = data.get('promocode_max_uses', 1) - + promo_group_id = data.get('promo_group_id') + promo_group_name = data.get('promo_group_name') + valid_until = None if expiry_days > 0: valid_until = datetime.utcnow() + timedelta(days=expiry_days) - + type_map = { "balance": PromoCodeType.BALANCE, "days": PromoCodeType.SUBSCRIPTION_DAYS, - "trial": PromoCodeType.TRIAL_SUBSCRIPTION + "trial": PromoCodeType.TRIAL_SUBSCRIPTION, + "group": PromoCodeType.PROMO_GROUP } - + promocode = await create_promocode( db=db, code=code, @@ -727,27 +821,31 @@ async def process_promocode_expiry( subscription_days=value if promo_type in ["days", "trial"] else 0, max_uses=max_uses, valid_until=valid_until, - created_by=db_user.id + created_by=db_user.id, + promo_group_id=promo_group_id if promo_type == "group" else None ) type_names = { - "balance": "Пополнение баланса", - "days": "Дни подписки", - "trial": "Тестовая подписка" + "balance": "Пополнение баланса", + "days": "Дни подписки", + "trial": "Тестовая подписка", + "group": "Промогруппа" } - + summary_text = f""" ✅ Промокод создан! 🎫 Код: {promocode.code} 📝 Тип: {type_names.get(promo_type)} """ - + if promo_type == "balance": summary_text += f"💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n" elif promo_type in ["days", "trial"]: summary_text += f"📅 Дней: {promocode.subscription_days}\n" - + elif promo_type == "group" and promo_group_name: + summary_text += f"🏷️ Промогруппа: {promo_group_name}\n" + summary_text += f"📊 Использований: {promocode.max_uses}\n" if promocode.valid_until: @@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list") dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create") dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_")) + dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_")) dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_")) dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_")) diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index 7f47d7a4..7ccb20cc 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -421,7 +421,8 @@ async def show_server_users( lines = [] for index, user in enumerate(page_users, start=start_index + 1): safe_user_name = html.escape(user.full_name) - lines.append(f"{index}. {safe_user_name}") + user_link = f'{safe_user_name}' + lines.append(f"{index}. {user_link}") text += "\n" + "\n".join(lines) else: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 32d3b02e..0328a938 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1008,7 +1008,8 @@ async def _render_user_subscription_overview( subscription = profile["subscription"] text = "📱 Подписка и настройки пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" + user_link = f'{user.full_name}' + text += f"👤 {user_link} (ID: {user.telegram_id})\n\n" keyboard = [] @@ -1168,7 +1169,8 @@ async def show_user_transactions( transactions = await get_user_transactions(db, user_id, limit=10) text = f"💳 Транзакции пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n" + user_link = f'{user.full_name}' + text += f"👤 {user_link} (ID: {user.telegram_id})\n" text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" if transactions: @@ -1445,16 +1447,41 @@ async def show_user_management( else: sections.append(texts.ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE) - if user.promo_group: - promo_group = user.promo_group + # Display promo groups + primary_group = user.get_primary_promo_group() + if primary_group: + sections.append( + texts.t( + "ADMIN_USER_PROMO_GROUPS_PRIMARY", + "⭐ Основная: {name} (Priority: {priority})", + ).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0)) + ) sections.append( texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format( - name=promo_group.name, - server_discount=promo_group.server_discount_percent, - traffic_discount=promo_group.traffic_discount_percent, - device_discount=promo_group.device_discount_percent, + name=primary_group.name, + server_discount=primary_group.server_discount_percent, + traffic_discount=primary_group.traffic_discount_percent, + device_discount=primary_group.device_discount_percent, ) ) + + # Show additional groups if any + if user.user_promo_groups and len(user.user_promo_groups) > 1: + additional_groups = [ + upg.promo_group for upg in user.user_promo_groups + if upg.promo_group and upg.promo_group.id != primary_group.id + ] + if additional_groups: + sections.append( + texts.t( + "ADMIN_USER_PROMO_GROUPS_ADDITIONAL", + "Дополнительные группы:", + ) + ) + for group in additional_groups: + sections.append( + f" • {group.name} (Priority: {getattr(group, 'priority', 0)})" + ) else: sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE) @@ -1538,12 +1565,13 @@ async def _build_user_referrals_view( if referral.username else "" ) + referral_link = f'{referral.full_name}' items.append( texts.t( "ADMIN_USER_REFERRALS_LIST_ITEM", "• {name} (ID: {telegram_id}{username_part})", ).format( - name=referral.full_name, + name=referral_link, telegram_id=referral.telegram_id, username_part=username_part, ) @@ -1892,20 +1920,43 @@ async def _render_user_promo_group( ) -> None: texts = get_texts(language) - current_group = user.promo_group + # Get primary and all user groups + primary_group = user.get_primary_promo_group() + user_group_ids = [upg.promo_group_id for upg in user.user_promo_groups] if user.user_promo_groups else [] + + # Build current groups section + if primary_group: + current_line = texts.t( + "ADMIN_USER_PROMO_GROUPS_PRIMARY", + "⭐ Основная: {name} (Priority: {priority})", + ).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0)) - if current_group: - current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name) discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format( - servers=current_group.server_discount_percent, - traffic=current_group.traffic_discount_percent, - devices=current_group.device_discount_percent, + servers=primary_group.server_discount_percent, + traffic=primary_group.traffic_discount_percent, + devices=primary_group.device_discount_percent, ) - current_group_id = current_group.id + + # Show additional groups if any + if len(user_group_ids) > 1: + additional_groups = [ + upg.promo_group for upg in user.user_promo_groups + if upg.promo_group and upg.promo_group.id != primary_group.id + ] + if additional_groups: + additional_line = "\n" + texts.t( + "ADMIN_USER_PROMO_GROUPS_ADDITIONAL", + "Дополнительные группы:", + ) + "\n" + for group in additional_groups: + additional_line += f" • {group.name} (Priority: {getattr(group, 'priority', 0)})\n" + discount_line += additional_line else: - current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE - discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE - current_group_id = None + current_line = texts.t( + "ADMIN_USER_PROMO_GROUPS_NONE", + "У пользователя нет промогрупп", + ) + discount_line = "" text = ( f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" @@ -1919,7 +1970,7 @@ async def _render_user_promo_group( reply_markup=get_user_promo_group_keyboard( promo_groups, user.id, - current_group_id, + user_group_ids, # Pass list of all group IDs language ) ) @@ -1957,6 +2008,13 @@ async def set_user_promo_group( db_user: User, db: AsyncSession ): + from app.database.crud.user_promo_group import ( + has_user_promo_group, + add_user_to_promo_group, + remove_user_from_promo_group, + count_user_promo_groups + ) + from app.database.crud.promo_group import get_promo_group_by_id parts = callback.data.split('_') user_id = int(parts[-2]) @@ -1969,49 +2027,52 @@ async def set_user_promo_group( await callback.answer("❌ Пользователь не найден", show_alert=True) return - if user.promo_group_id == group_id: - await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True) - return + # Check if user already has this group + has_group = await has_user_promo_group(db, user_id, group_id) - user_service = UserService() - success, updated_user, new_group, old_group = await user_service.update_user_promo_group( - db, - user_id, - group_id - ) + if has_group: + # Remove group + # Check if it's the last group + groups_count = await count_user_promo_groups(db, user_id) + if groups_count <= 1: + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST", + "❌ Нельзя удалить последнюю промогруппу", + ), + show_alert=True + ) + return - if not success or not updated_user or not new_group: - await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True) - return + group = await get_promo_group_by_id(db, group_id) + await remove_user_from_promo_group(db, user_id, group_id) + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_GROUP_REMOVED", + "🗑 Группа «{name}» удалена", + ).format(name=group.name if group else ""), + show_alert=True + ) + else: + # Add group + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True) + return + await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin") + await callback.answer( + texts.t( + "ADMIN_USER_PROMO_GROUP_ADDED", + "✅ Группа «{name}» добавлена", + ).format(name=group.name), + show_alert=True + ) + + # Refresh user data and show updated list + user = await get_user_by_id(db, user_id) promo_groups = await get_promo_groups_with_counts(db) - - await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups) - await callback.answer( - texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name), - show_alert=True - ) - - try: - notification_service = AdminNotificationService(callback.bot) - reason = ( - f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})" - ) - await notification_service.send_user_promo_group_change_notification( - db, - updated_user, - old_group, - new_group, - reason=reason, - initiator=db_user, - automatic=False, - ) - except Exception as notify_error: - logger.error( - "Ошибка отправки уведомления о смене промогруппы пользователя %s: %s", - updated_user.telegram_id, - notify_error, - ) + await _render_user_promo_group(callback.message, db_user.language, user, promo_groups) @@ -2280,9 +2341,10 @@ async def show_inactive_users( text = f"🗑️ Неактивные пользователи\n" text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n" - - for user in inactive_users[:10]: - text += f"👤 {user.full_name}\n" + + for user in inactive_users[:10]: + user_link = f'{user.full_name}' + text += f"👤 {user_link}\n" text += f"🆔 {user.telegram_id}\n" last_activity_display = ( format_time_ago(user.last_activity, db_user.language) @@ -2384,7 +2446,8 @@ async def show_user_statistics( campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) text = f"📊 Статистика пользователя\n\n" - text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" + user_link = f'{user.full_name}' + text += f"👤 {user_link} (ID: {user.telegram_id})\n\n" text += f"Основная информация:\n" text += f"• Дней с регистрации: {profile['registration_days']}\n" @@ -4005,7 +4068,8 @@ async def admin_buy_subscription( ]) text = f"💳 Покупка подписки для пользователя\n\n" - text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + target_user_link = f'{target_user.full_name}' + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ" devices_limit = subscription.device_limit @@ -4096,7 +4160,8 @@ async def admin_buy_subscription_confirm( return text = f"💳 Подтверждение покупки подписки\n\n" - text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + target_user_link = f'{target_user.full_name}' + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" text += f"📅 Период подписки: {period_days} дней\n" text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n" text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n" @@ -4314,9 +4379,10 @@ async def admin_buy_subscription_execute( else: message = "❌ Ошибка: у пользователя нет существующей подписки" + target_user_link = f'{target_user.full_name}' await callback.message.edit_text( f"{message}\n\n" - f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n" + f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" f"💰 Списано: {settings.format_price(price_kopeks)}\n" f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ @@ -4324,7 +4390,8 @@ async def admin_buy_subscription_execute( text="⬅️ Назад к подписке", callback_data=f"admin_user_subscription_{user_id}" )] - ]) + ]), + parse_mode="HTML" ) try: @@ -4541,7 +4608,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( set_user_promo_group, - F.data.startswith("admin_user_promo_group_set_") + F.data.startswith("admin_user_promo_group_toggle_") ) dp.callback_query.register( diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index fb0b21b1..db1abbc9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -827,20 +827,28 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str = def get_user_promo_group_keyboard( promo_groups: List[Tuple[Any, int]], user_id: int, - current_group_id: Optional[int], + current_group_ids, # Can be Optional[int] or List[int] language: str = "ru" ) -> InlineKeyboardMarkup: texts = get_texts(language) + # Ensure current_group_ids is a list + if current_group_ids is None: + current_group_ids = [] + elif isinstance(current_group_ids, int): + current_group_ids = [current_group_ids] + keyboard: List[List[InlineKeyboardButton]] = [] for group, members_count in promo_groups: - prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥" + # Check if user has this group + has_group = group.id in current_group_ids + prefix = "✅" if has_group else "👥" count_text = f" ({members_count})" if members_count else "" keyboard.append([ InlineKeyboardButton( text=f"{prefix} {group.name}{count_text}", - callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}" + callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}" ) ]) @@ -887,6 +895,10 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton( text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"), callback_data="promo_type_trial" + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"), + callback_data="promo_type_group" ) ], [ diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 5a41a172..98df0619 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1965,15 +1965,33 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> if isinstance(price_info, dict): final_price = price_info.get("final") + original_price = price_info.get("original", 0) if final_price is None: final_price = price_info.get("original", 0) else: final_price = price_info + original_price = price_info period_display = format_period_description(days, language) + + # Show discount if there is one + if original_price > final_price and original_price > 0: + discount_percent = ((original_price - final_price) * 100) // original_price + button_text = ( + f"📅 {period_display} - " + f"{texts.format_price(original_price)} " + f"{texts.format_price(final_price)} " + f"(-{discount_percent}%)" + ) + # Add fire emojis for 360 days + if days == 360: + button_text = f"🔥 {button_text} 🔥" + else: + button_text = f"📅 {period_display} - {texts.format_price(final_price)}" + keyboard.append([ InlineKeyboardButton( - text=f"📅 {period_display} - {texts.format_price(final_price)}", + text=button_text, callback_data=f"extend_period_{days}" ) ]) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c7f09c43..8f8eaa80 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -314,6 +314,7 @@ "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial", + "ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group", "ADMIN_PROMO_GROUPS": "💳 Promo groups", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", @@ -370,7 +371,18 @@ "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", - "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.", + "ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}", + "ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):", + "ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority", + "ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer", + "ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})", + "ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:", + "ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups", + "ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added", + "ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed", + "ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group", "ADMIN_PROMO_OFFERS_TITLE": "🎯 Promo offers\n\nSelect a template to configure:", "ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.", "ADMIN_PROMO_OFFER_ALLOWED": "Available segments:", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index fc188245..1d6b72a7 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -314,6 +314,7 @@ "ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс", "ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки", "ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал", + "ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа", "ADMIN_PROMO_GROUPS": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", @@ -371,6 +372,17 @@ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}", + "ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):", + "ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):", + "ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет", + "ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом", + "ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})", + "ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:", + "ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп", + "ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена", + "ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена", + "ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу", "ADMIN_PROMO_OFFERS_TITLE": "🎯 Промо-предложения\n\nВыберите предложение для настройки:", "ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.", "ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:", diff --git a/app/localization/texts.py b/app/localization/texts.py index b0d05652..178aba64 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -29,14 +29,36 @@ def _get_cached_rules_value(language: str) -> str: def _build_dynamic_values(language: str) -> Dict[str, Any]: language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower() + # Helper function to format period with discount + def format_period_with_discount(label: str, period_days: int, base_price: int) -> str: + discount_percent = settings.get_base_promo_group_period_discount(period_days) + if discount_percent > 0: + # Calculate discounted price + from app.utils.pricing_utils import apply_percentage_discount + discounted_price, _ = apply_percentage_discount(base_price, discount_percent) + result = format_period_option_label( + label, + discounted_price, + base_price, + discount_percent + ) + else: + result = format_period_option_label(label, base_price) + + # Add fire emojis for 360 days period + if period_days == 360 and discount_percent > 0: + result = f"🔥 {result} 🔥" + + return result + if language_code == "ru": return { - "PERIOD_14_DAYS": format_period_option_label("📅 14 дней", settings.PRICE_14_DAYS), - "PERIOD_30_DAYS": format_period_option_label("📅 30 дней", settings.PRICE_30_DAYS), - "PERIOD_60_DAYS": format_period_option_label("📅 60 дней", settings.PRICE_60_DAYS), - "PERIOD_90_DAYS": format_period_option_label("📅 90 дней", settings.PRICE_90_DAYS), - "PERIOD_180_DAYS": format_period_option_label("📅 180 дней", settings.PRICE_180_DAYS), - "PERIOD_360_DAYS": format_period_option_label("📅 360 дней", settings.PRICE_360_DAYS), + "PERIOD_14_DAYS": format_period_with_discount("📅 14 дней", 14, settings.PRICE_14_DAYS), + "PERIOD_30_DAYS": format_period_with_discount("📅 30 дней", 30, settings.PRICE_30_DAYS), + "PERIOD_60_DAYS": format_period_with_discount("📅 60 дней", 60, settings.PRICE_60_DAYS), + "PERIOD_90_DAYS": format_period_with_discount("📅 90 дней", 90, settings.PRICE_90_DAYS), + "PERIOD_180_DAYS": format_period_with_discount("📅 180 дней", 180, settings.PRICE_180_DAYS), + "PERIOD_360_DAYS": format_period_with_discount("📅 360 дней", 360, settings.PRICE_360_DAYS), "TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", "TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", "TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", @@ -56,12 +78,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: if language_code == "en": return { - "PERIOD_14_DAYS": format_period_option_label("📅 14 days", settings.PRICE_14_DAYS), - "PERIOD_30_DAYS": format_period_option_label("📅 30 days", settings.PRICE_30_DAYS), - "PERIOD_60_DAYS": format_period_option_label("📅 60 days", settings.PRICE_60_DAYS), - "PERIOD_90_DAYS": format_period_option_label("📅 90 days", settings.PRICE_90_DAYS), - "PERIOD_180_DAYS": format_period_option_label("📅 180 days", settings.PRICE_180_DAYS), - "PERIOD_360_DAYS": format_period_option_label("📅 360 days", settings.PRICE_360_DAYS), + "PERIOD_14_DAYS": format_period_with_discount("📅 14 days", 14, settings.PRICE_14_DAYS), + "PERIOD_30_DAYS": format_period_with_discount("📅 30 days", 30, settings.PRICE_30_DAYS), + "PERIOD_60_DAYS": format_period_with_discount("📅 60 days", 60, settings.PRICE_60_DAYS), + "PERIOD_90_DAYS": format_period_with_discount("📅 90 days", 90, settings.PRICE_90_DAYS), + "PERIOD_180_DAYS": format_period_with_discount("📅 180 days", 180, settings.PRICE_180_DAYS), + "PERIOD_360_DAYS": format_period_with_discount("📅 360 days", 360, settings.PRICE_360_DAYS), "TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}", "TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}", "TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}", diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 974e523e..171a6bf0 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -397,14 +397,14 @@ class YooKassaPaymentMixin: full_user_result = await db.execute( select(User) .options(selectinload(User.subscription)) - .options(selectinload(User.promo_group)) + .options(selectinload(User.user_promo_groups)) .where(User.id == user.id) ) full_user = full_user_result.scalar_one_or_none() - + # Используем обновленные данные или исходные, если не удалось обновить subscription = full_user.subscription if full_user else getattr(user, "subscription", None) - promo_group = full_user.promo_group if full_user else getattr(user, "promo_group", None) + promo_group = full_user.get_primary_promo_group() if full_user else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None) # Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой user_for_referrer = full_user if full_user else user @@ -653,7 +653,7 @@ class YooKassaPaymentMixin: full_user_result = await db.execute( select(User) .options(selectinload(User.subscription)) - .options(selectinload(User.promo_group)) + .options(selectinload(User.user_promo_groups)) .where(User.id == user.id) ) full_user = full_user_result.scalar_one_or_none() diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 21d48911..e2c6cd48 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -92,18 +92,15 @@ async def maybe_assign_promo_group_by_total_spent( db: AsyncSession, user_id: int, ) -> Optional[PromoGroup]: + from app.database.crud.user_promo_group import add_user_to_promo_group, has_user_promo_group + user = await db.get(User, user_id) if not user: logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) return None - old_group = None - if user.promo_group_id: - try: - await db.refresh(user, attribute_names=["promo_group"]) - except Exception: - pass - old_group = getattr(user, "promo_group", None) + # Получаем текущую primary промогруппу + old_group = user.get_primary_promo_group() total_spent = await get_user_total_spent_kopeks(db, user_id) if total_spent <= 0: @@ -120,7 +117,6 @@ async def maybe_assign_promo_group_by_total_spent( return None try: - previous_group_id = user.promo_group_id target_threshold = target_group.auto_assign_total_spent_kopeks or 0 if target_threshold <= previous_threshold: @@ -133,9 +129,12 @@ async def maybe_assign_promo_group_by_total_spent( ) return None - if user.auto_promo_group_assigned and target_group.id == previous_group_id: + # Проверяем, есть ли уже эта группа у пользователя + already_has_group = await has_user_promo_group(db, user_id, target_group.id) + + if user.auto_promo_group_assigned and already_has_group: logger.debug( - "Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется", + "Пользователь %s уже имеет промогруппу '%s', повторная выдача не требуется", user.telegram_id, target_group.name, ) @@ -150,18 +149,18 @@ async def maybe_assign_promo_group_by_total_spent( user.auto_promo_group_threshold_kopeks = target_threshold user.updated_at = datetime.utcnow() - if target_group.id != previous_group_id: - user.promo_group_id = target_group.id - user.promo_group = target_group + if not already_has_group: + # Добавляем новую промогруппу к существующим + await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto") logger.info( - "🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s ₽", + "🤖 Пользователю %s добавлена промогруппа '%s' за траты %s ₽", user.telegram_id, target_group.name, total_spent / 100, ) else: logger.info( - "🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение", + "🤖 Пользователь %s уже имеет промогруппу '%s', отмечаем автоприсвоение", user.telegram_id, target_group.name, ) @@ -169,7 +168,7 @@ async def maybe_assign_promo_group_by_total_spent( await db.commit() await db.refresh(user) - if target_group.id != previous_group_id: + if not already_has_group: await _notify_admins_about_auto_assignment( db, user, diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index 2ce3fa91..dee20837 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -9,6 +9,10 @@ from app.database.crud.promocode import ( ) from app.database.crud.user import add_user_balance, get_user_by_id from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id +from app.database.crud.user_promo_group import ( + has_user_promo_group, add_user_to_promo_group +) +from app.database.crud.promo_group import get_promo_group_by_id from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService @@ -56,6 +60,47 @@ class PromoCodeService: logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}") + # Assign promo group if promocode has one + if promocode.promo_group_id: + try: + # Check if user already has this promo group + has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id) + + if not has_group: + # Get promo group details + promo_group = await get_promo_group_by_id(db, promocode.promo_group_id) + + if promo_group: + # Add promo group to user + await add_user_to_promo_group( + db, + user_id, + promocode.promo_group_id, + assigned_by="promocode" + ) + + logger.info( + f"🎯 Пользователю {user.telegram_id} назначена промогруппа '{promo_group.name}' " + f"(приоритет: {promo_group.priority}) через промокод {code}" + ) + + # Add to result description + result_description += f"\n🎁 Назначена промогруппа: {promo_group.name}" + else: + logger.warning( + f"⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}" + ) + else: + logger.info( + f"ℹ️ Пользователь {user.telegram_id} уже имеет промогруппу ID {promocode.promo_group_id}" + ) + except Exception as pg_error: + logger.error( + f"❌ Ошибка назначения промогруппы для пользователя {user.telegram_id} " + f"при активации промокода {code}: {pg_error}" + ) + # Don't fail the whole promocode activation if promo group assignment fails + await create_promocode_use(db, promocode.id, user_id) promocode.current_uses += 1 @@ -71,6 +116,7 @@ class PromoCodeService: "max_uses": promocode.max_uses, "current_uses": promocode.current_uses, "valid_until": promocode.valid_until, + "promo_group_id": promocode.promo_group_id, } return { diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index d546f15e..deb302fc 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -196,11 +196,16 @@ async def _prepare_auto_extend_context( def _apply_extension_updates(context: AutoExtendContext) -> None: + """ + Применяет обновления лимитов подписки (трафик, устройства, серверы). + НЕ изменяет is_trial - это делается позже после успешного коммита продления. + """ subscription = context.subscription + # Обновляем лимиты для триальной подписки if subscription.is_trial: - subscription.is_trial = False - subscription.status = "active" + # НЕ удаляем триал здесь! Это будет сделано после успешного extend_subscription() + # subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала if context.traffic_limit_gb is not None: subscription.traffic_limit_gb = context.traffic_limit_gb if context.device_limit is not None: @@ -208,6 +213,7 @@ def _apply_extension_updates(context: AutoExtendContext) -> None: if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] else: + # Обновляем лимиты для платной подписки if context.traffic_limit_gb not in (None, 0): subscription.traffic_limit_gb = context.traffic_limit_gb if ( @@ -275,6 +281,7 @@ async def _auto_extend_subscription( subscription = prepared.subscription old_end_date = subscription.end_date + was_trial = subscription.is_trial # Запоминаем, была ли подписка триальной _apply_extension_updates(prepared) @@ -284,6 +291,18 @@ async def _auto_extend_subscription( subscription, prepared.period_days, ) + + # НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления + if was_trial and subscription.is_trial: + subscription.is_trial = False + subscription.status = "active" + await db.commit() + logger.info( + "✅ Триал конвертирован в платную подписку %s для пользователя %s", + subscription.id, + user.telegram_id, + ) + except Exception as error: # pragma: no cover - defensive logging logger.error( "❌ Автопокупка: не удалось продлить подписку пользователя %s: %s", @@ -291,6 +310,8 @@ async def _auto_extend_subscription( error, exc_info=True, ) + # НОВОЕ: Откатываем изменения при ошибке + await db.rollback() return False transaction = None diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 36fb3074..dced5d42 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -470,7 +470,7 @@ class SubscriptionService: base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - promo_group = promo_group or (user.promo_group if user else None) + promo_group = promo_group or (user.get_primary_promo_group() if user else None) traffic_price = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _resolve_discount_percent( @@ -570,7 +570,7 @@ class SubscriptionService: if user is None: user = getattr(subscription, "user", None) - promo_group = promo_group or (user.promo_group if user else None) + promo_group = promo_group or (user.get_primary_promo_group() if user else None) servers_price, _ = await self.get_countries_price_by_uuids( subscription.connected_squads, @@ -798,7 +798,7 @@ class SubscriptionService: base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - promo_group = promo_group or (user.promo_group if user else None) + promo_group = promo_group or (user.get_primary_promo_group() if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _resolve_discount_percent( @@ -910,7 +910,7 @@ class SubscriptionService: if user is None: user = getattr(subscription, "user", None) - promo_group = promo_group or (user.promo_group if user else None) + promo_group = promo_group or (user.get_primary_promo_group() if user else None) servers_price_per_month, _ = await self.get_countries_price_by_uuids( subscription.connected_squads, diff --git a/app/states.py b/app/states.py index 981c314a..9373c92f 100644 --- a/app/states.py +++ b/app/states.py @@ -48,6 +48,7 @@ class AdminStates(StatesGroup): setting_promocode_value = State() setting_promocode_uses = State() setting_promocode_expiry = State() + selecting_promo_group = State() creating_campaign_name = State() creating_campaign_start = State() @@ -71,6 +72,7 @@ class AdminStates(StatesGroup): confirming_broadcast = State() creating_promo_group_name = State() + creating_promo_group_priority = State() creating_promo_group_traffic_discount = State() creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() @@ -79,6 +81,7 @@ class AdminStates(StatesGroup): editing_promo_group_menu = State() editing_promo_group_name = State() + editing_promo_group_priority = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 88c9602e..2a874a71 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -139,7 +139,7 @@ async def compute_simple_subscription_price( promo_group = await get_promo_group_by_id(db, int(promo_group_id)) if promo_group is None and user is not None: - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() period_discount_percent = resolve_discount_percent( user, @@ -313,19 +313,43 @@ def format_period_description(days: int, language: str = "ru") -> str: return f"{days} days ({months} {month_word})" -def format_period_option_label(label: str, price: int) -> str: +def format_period_option_label( + label: str, + price: int, + original_price: int = 0, + discount_percent: int = 0 +) -> str: """Return a period option label with price when it's greater than zero. When the price is zero or negative, the price suffix is omitted so that the option does not misleadingly show "0" as the cost of the period. This keeps the UI consistent when pricing is calculated dynamically based on other parameters such as servers or devices. + + Args: + label: The base label text (e.g., "📅 30 дней") + price: The final price after discount + original_price: The original price before discount (optional) + discount_percent: The discount percentage (optional) + + Returns: + Formatted label with price and discount info if applicable """ - if price and price > 0: - return f"{label} - {settings.format_price(price)}" + if not price or price <= 0: + return label - return label + # If there's a discount, show crossed-out original price and discount percentage + if original_price > 0 and discount_percent > 0 and original_price > price: + return ( + f"{label} - " + f"{settings.format_price(original_price)} " + f"{settings.format_price(price)} " + f"(-{discount_percent}%)" + ) + + # No discount, show price only + return f"{label} - {settings.format_price(price)}" def validate_pricing_calculation( diff --git a/tests/conftest.py b/tests/conftest.py index 242eff3a..46346e27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,9 +6,14 @@ import os import sys import types from datetime import datetime, timezone +from pathlib import Path import pytest +# Add project root to Python path for imports +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + # Подменяем параметры подключения к БД, чтобы SQLAlchemy не требовал aiosqlite. os.environ.setdefault("DATABASE_MODE", "postgresql") os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/test_db") diff --git a/tests/crud/__init__.py b/tests/crud/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/crud/test_promocode_crud.py b/tests/crud/test_promocode_crud.py new file mode 100644 index 00000000..f462d860 --- /dev/null +++ b/tests/crud/test_promocode_crud.py @@ -0,0 +1,142 @@ +""" +Tests for Promocode CRUD operations - focus on promo_group_id integration +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from types import SimpleNamespace + +from app.database.crud.promocode import ( + create_promocode, + get_promocode_by_code, + get_promocodes_list, +) +from app.database.models import PromoCodeType, PromoCode + +# Import fixtures +from tests.fixtures.promocode_fixtures import ( + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +) + + +async def test_create_promocode_with_promo_group_id( + sample_promo_group, + mock_db_session, +): + """ + Test creating a promocode with promo_group_id + + Scenario: + - Create PROMO_GROUP type promocode + - promo_group_id should be saved + - Database operations should be called correctly + """ + # Execute + promocode = await create_promocode( + db=mock_db_session, + code="TESTGROUP", + type=PromoCodeType.PROMO_GROUP, + balance_bonus_kopeks=0, + subscription_days=0, + max_uses=100, + valid_until=None, + created_by=1, + promo_group_id=sample_promo_group.id + ) + + # Assertions + assert promocode.code == "TESTGROUP" + assert promocode.type == PromoCodeType.PROMO_GROUP.value + assert promocode.promo_group_id == sample_promo_group.id + + # Verify database operations + mock_db_session.add.assert_called_once() + mock_db_session.commit.assert_awaited_once() + mock_db_session.refresh.assert_awaited_once() + + +async def test_create_promocode_without_promo_group_id(mock_db_session): + """ + Test creating a promocode without promo_group_id (other types) + + Scenario: + - Create BALANCE type promocode + - promo_group_id should be None + """ + # Execute + promocode = await create_promocode( + db=mock_db_session, + code="BALANCE100", + type=PromoCodeType.BALANCE, + balance_bonus_kopeks=10000, + subscription_days=0, + max_uses=50, + valid_until=None, + created_by=1, + promo_group_id=None + ) + + # Assertions + assert promocode.code == "BALANCE100" + assert promocode.type == PromoCodeType.BALANCE.value + assert promocode.promo_group_id is None + + +async def test_get_promocode_by_code_loads_promo_group( + sample_promocode_promo_group, + mock_db_session, +): + """ + Test that get_promocode_by_code loads promo_group relationship + + Scenario: + - Query promocode by code + - Verify selectinload was used for promo_group + - Verify promo_group data is accessible + """ + # Setup mock result + mock_result = AsyncMock() + mock_result.scalar_one_or_none = lambda: sample_promocode_promo_group + mock_db_session.execute = AsyncMock(return_value=mock_result) + + # Execute + promocode = await get_promocode_by_code(mock_db_session, "VIPGROUP") + + # Assertions + assert promocode is not None + assert promocode.code == "VIPGROUP" + assert promocode.promo_group is not None + assert promocode.promo_group.name == "Test VIP Group" + + # Verify execute was called (query was executed) + mock_db_session.execute.assert_awaited_once() + + +async def test_get_promocodes_list_loads_promo_groups( + sample_promocode_promo_group, + mock_db_session, +): + """ + Test that get_promocodes_list loads promo_group relationships + + Scenario: + - Query list of promocodes + - Verify selectinload was used for promo_group + - Verify all promocodes have accessible promo_group data + """ + # Setup mock result + mock_result = AsyncMock() + mock_result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[sample_promocode_promo_group]))) + mock_db_session.execute = AsyncMock(return_value=mock_result) + + # Execute + promocodes = await get_promocodes_list(mock_db_session, offset=0, limit=10) + + # Assertions + assert len(promocodes) == 1 + assert promocodes[0].promo_group is not None + assert promocodes[0].promo_group.name == "Test VIP Group" + + # Verify execute was called + mock_db_session.execute.assert_awaited_once() diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..85ce89b6 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Test fixtures package""" diff --git a/tests/fixtures/promocode_fixtures.py b/tests/fixtures/promocode_fixtures.py new file mode 100644 index 00000000..ad57ab55 --- /dev/null +++ b/tests/fixtures/promocode_fixtures.py @@ -0,0 +1,206 @@ +""" +Fixtures for promocode and promo group testing +""" +import pytest +from types import SimpleNamespace +from unittest.mock import AsyncMock +from datetime import datetime, timedelta + +from app.database.models import PromoCodeType + + +@pytest.fixture +def sample_promo_group(): + """Sample PromoGroup object for testing""" + return SimpleNamespace( + id=1, + name="Test VIP Group", + priority=50, + server_discount_percent=20, + traffic_discount_percent=15, + device_discount_percent=10, + period_discounts={30: 10, 60: 15, 90: 20}, + is_default=False, + auto_assign_total_spent_kopeks=None, + auto_assign_enabled=False, + addon_discount_enabled=True + ) + + +@pytest.fixture +def sample_user(): + """Sample User object for testing""" + return SimpleNamespace( + id=1, + telegram_id=123456789, + username="testuser", + full_name="Test User", + balance_kopeks=0, + language="ru", + has_had_paid_subscription=False, + total_spent_kopeks=0 + ) + + +@pytest.fixture +def sample_promocode_balance(): + """Balance type promocode""" + return SimpleNamespace( + id=1, + code="BALANCE100", + type=PromoCodeType.BALANCE.value, + balance_bonus_kopeks=10000, # 100 rubles + subscription_days=0, + max_uses=100, + current_uses=10, + is_active=True, + promo_group_id=None, + promo_group=None, + valid_until=None, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + created_by=1 + ) + + +@pytest.fixture +def sample_promocode_subscription(): + """Subscription days type promocode""" + return SimpleNamespace( + id=2, + code="SUB30", + type=PromoCodeType.SUBSCRIPTION_DAYS.value, + balance_bonus_kopeks=0, + subscription_days=30, + max_uses=50, + current_uses=5, + is_active=True, + promo_group_id=None, + promo_group=None, + valid_until=datetime.utcnow() + timedelta(days=60), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + created_by=1 + ) + + +@pytest.fixture +def sample_promocode_promo_group(sample_promo_group): + """Promo group type promocode""" + return SimpleNamespace( + id=3, + code="VIPGROUP", + type=PromoCodeType.PROMO_GROUP.value, + balance_bonus_kopeks=0, + subscription_days=0, + max_uses=100, + current_uses=20, + is_active=True, + promo_group_id=sample_promo_group.id, + promo_group=sample_promo_group, + valid_until=None, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + created_by=1 + ) + + +@pytest.fixture +def sample_promocode_invalid(): + """Invalid/expired promocode""" + return SimpleNamespace( + id=4, + code="EXPIRED", + type=PromoCodeType.BALANCE.value, + balance_bonus_kopeks=5000, + subscription_days=0, + max_uses=10, + current_uses=10, # Used up + is_active=False, + promo_group_id=None, + promo_group=None, + valid_until=datetime.utcnow() - timedelta(days=1), # Expired + created_at=datetime.utcnow() - timedelta(days=30), + updated_at=datetime.utcnow(), + created_by=1 + ) + + +@pytest.fixture +def mock_db_session(): + """Mock AsyncSession""" + db = AsyncMock() + db.commit = AsyncMock() + db.rollback = AsyncMock() + db.refresh = AsyncMock() + db.get = AsyncMock() + db.execute = AsyncMock() + db.add = AsyncMock() + return db + + +@pytest.fixture +def mock_has_user_promo_group(): + """Mock has_user_promo_group function""" + return AsyncMock(return_value=False) + + +@pytest.fixture +def mock_add_user_to_promo_group(): + """Mock add_user_to_promo_group function""" + return AsyncMock() + + +@pytest.fixture +def mock_get_promo_group_by_id(sample_promo_group): + """Mock get_promo_group_by_id function""" + return AsyncMock(return_value=sample_promo_group) + + +@pytest.fixture +def mock_get_user_by_id(sample_user): + """Mock get_user_by_id function""" + return AsyncMock(return_value=sample_user) + + +@pytest.fixture +def mock_get_promocode_by_code(): + """Mock get_promocode_by_code function""" + return AsyncMock() + + +@pytest.fixture +def mock_check_user_promocode_usage(): + """Mock check_user_promocode_usage function""" + return AsyncMock(return_value=False) + + +@pytest.fixture +def mock_create_promocode_use(): + """Mock create_promocode_use function""" + return AsyncMock() + + +@pytest.fixture +def mock_remnawave_service(): + """Mock RemnaWaveService""" + service = AsyncMock() + service.create_remnawave_user = AsyncMock() + service.update_remnawave_user = AsyncMock() + return service + + +@pytest.fixture +def mock_subscription_service(): + """Mock SubscriptionService""" + service = AsyncMock() + service.create_remnawave_user = AsyncMock() + service.update_remnawave_user = AsyncMock() + return service + + +# Helper function to create a valid promocode property mock +def make_promocode_valid(promocode): + """Helper to make promocode appear valid (is_valid property)""" + promocode.is_valid = True + return promocode diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/test_promocode_promo_group_flow.py b/tests/integration/test_promocode_promo_group_flow.py new file mode 100644 index 00000000..2b19fcee --- /dev/null +++ b/tests/integration/test_promocode_promo_group_flow.py @@ -0,0 +1,338 @@ +""" +Integration tests for promo code with promo group full workflow + +These tests validate the complete flow from creating a promo group, +creating a promocode, to activating it and verifying the user receives +the promo group assignment. +""" +import pytest +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace + +from app.services.promocode_service import PromoCodeService +from app.database.models import PromoCodeType + +# Import fixtures +from tests.fixtures.promocode_fixtures import ( + sample_promo_group, + sample_user, + sample_promocode_promo_group, + mock_db_session, +) + + +async def test_promo_group_promocode_full_workflow( + monkeypatch, + sample_user, + sample_promo_group, + mock_db_session, +): + """ + Integration test: Full workflow of promo group promocode + + Flow: + 1. Promo group exists (VIP Group, priority 50) + 2. Admin creates PROMO_GROUP type promocode + 3. User activates promocode + 4. User is added to promo group + 5. Usage is recorded + 6. Counter is incremented + + This test validates the entire integration between: + - Promocode CRUD + - Promo group CRUD + - User promo group CRUD + - Promocode service + """ + # Setup: Create a PROMO_GROUP promocode + promocode = SimpleNamespace( + id=1, + code="INTEGRATIONTEST", + type=PromoCodeType.PROMO_GROUP.value, + balance_bonus_kopeks=0, + subscription_days=0, + max_uses=100, + current_uses=0, + is_active=True, + is_valid=True, + promo_group_id=sample_promo_group.id, + promo_group=sample_promo_group, + valid_until=None + ) + + # Mock all CRUD operations + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=promocode) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute: User activates promocode + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "INTEGRATIONTEST" + ) + + # Verify: Activation successful + assert result["success"] is True + assert "Test VIP Group" in result["description"] + + # Verify: All steps were executed in correct order + get_user_mock.assert_awaited_once_with(mock_db_session, sample_user.id) + get_promocode_mock.assert_awaited_once_with(mock_db_session, "INTEGRATIONTEST") + check_usage_mock.assert_awaited_once_with(mock_db_session, sample_user.id, promocode.id) + + # Verify: Promo group assignment flow + get_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_promo_group.id) + has_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_user.id, + sample_promo_group.id + ) + add_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_user.id, + sample_promo_group.id, + assigned_by="promocode" + ) + + # Verify: Usage recorded + create_usage_mock.assert_awaited_once_with( + mock_db_session, + promocode.id, + sample_user.id + ) + + # Verify: Counter incremented + assert promocode.current_uses == 1 + + # Verify: Database committed + mock_db_session.commit.assert_awaited() + + +async def test_duplicate_promo_group_assignment_edge_case( + monkeypatch, + sample_user, + sample_promo_group, + mock_db_session, +): + """ + Edge case: User already has promo group from previous promocode + + Scenario: + 1. User previously activated a promo group promocode + 2. User already has the VIP Group + 3. User activates another promocode for same group + 4. System should not duplicate the assignment + 5. Activation should still succeed + """ + promocode = SimpleNamespace( + id=2, + code="DUPLICATE", + type=PromoCodeType.PROMO_GROUP.value, + balance_bonus_kopeks=0, + subscription_days=0, + max_uses=100, + current_uses=5, + is_active=True, + is_valid=True, + promo_group_id=sample_promo_group.id, + promo_group=sample_promo_group, + valid_until=None + ) + + # Mock CRUD operations + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=promocode) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + # User ALREADY HAS this promo group + has_promo_group_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "DUPLICATE" + ) + + # Verify: Activation still successful + assert result["success"] is True + + # Verify: add_user_to_promo_group was NOT called (no duplicate) + add_promo_group_mock.assert_not_awaited() + + # Verify: Usage was still recorded + create_usage_mock.assert_awaited_once() + + # Verify: Counter still incremented + assert promocode.current_uses == 6 + + +async def test_missing_promo_group_graceful_failure( + monkeypatch, + sample_user, + mock_db_session, +): + """ + Edge case: Promocode references deleted/non-existent promo group + + Scenario: + 1. Promocode was created with promo_group_id=999 + 2. Promo group was later deleted + 3. User activates promocode + 4. System should handle gracefully (log warning, continue) + 5. Promocode effects should still apply + 6. No promo group is assigned (can't assign non-existent group) + """ + # Promocode with non-existent promo_group_id + promocode = SimpleNamespace( + id=3, + code="ORPHANED", + type=PromoCodeType.PROMO_GROUP.value, + balance_bonus_kopeks=0, + subscription_days=0, + max_uses=10, + current_uses=0, + is_active=True, + is_valid=True, + promo_group_id=999, # Non-existent + promo_group=None, + valid_until=None + ) + + # Mock CRUD operations + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=promocode) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + # Promo group NOT FOUND + get_promo_group_mock = AsyncMock(return_value=None) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "ORPHANED" + ) + + # Verify: Activation STILL successful (graceful degradation) + assert result["success"] is True + + # Verify: Attempted to fetch promo group + get_promo_group_mock.assert_awaited_once_with(mock_db_session, 999) + + # Verify: add_user_to_promo_group was NOT called (group doesn't exist) + add_promo_group_mock.assert_not_awaited() + + # Verify: Usage was still recorded (promocode still works) + create_usage_mock.assert_awaited_once() + + # Verify: Counter still incremented + assert promocode.current_uses == 1 diff --git a/tests/services/test_promocode_service.py b/tests/services/test_promocode_service.py new file mode 100644 index 00000000..58cb429d --- /dev/null +++ b/tests/services/test_promocode_service.py @@ -0,0 +1,584 @@ +""" +Tests for PromoCodeService - focus on promo group integration +""" +import pytest +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace + +from app.services.promocode_service import PromoCodeService +from app.database.models import PromoCodeType + +# Import fixtures +from tests.fixtures.promocode_fixtures import ( + sample_promo_group, + sample_user, + sample_promocode_promo_group, + mock_db_session, + mock_has_user_promo_group, + mock_add_user_to_promo_group, + mock_get_promo_group_by_id, + mock_get_user_by_id, + mock_get_promocode_by_code, + mock_check_user_promocode_usage, + mock_create_promocode_use, +) + + +async def test_activate_promo_group_promocode_success( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test successful activation of PROMO_GROUP type promocode + + Scenario: + - User activates valid promo group promocode + - User doesn't have this promo group yet + - User is successfully added to promo group + - Result includes promo group name + """ + # Make promocode valid + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Assertions + assert result["success"] is True + assert "Test VIP Group" in result["description"] + assert result["promocode"]["promo_group_id"] == sample_promo_group.id + + # Verify promo group was fetched + get_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_promo_group.id + ) + + # Verify user promo group check + has_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_user.id, + sample_promo_group.id + ) + + # Verify promo group assignment + add_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_user.id, + sample_promo_group.id, + assigned_by="promocode" + ) + + # Verify usage recorded + create_usage_mock.assert_awaited_once_with( + mock_db_session, + sample_promocode_promo_group.id, + sample_user.id + ) + + # Verify counter incremented + assert sample_promocode_promo_group.current_uses == 21 + mock_db_session.commit.assert_awaited() + + +async def test_activate_promo_group_user_already_has_group( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test activation when user already has the promo group + + Scenario: + - User activates promo group promocode + - User already has this promo group + - add_user_to_promo_group should NOT be called + - Activation still succeeds + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + # User ALREADY HAS the promo group + has_promo_group_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Assertions + assert result["success"] is True + + # Verify promo group assignment was NOT called + add_promo_group_mock.assert_not_awaited() + + # But usage was still recorded + create_usage_mock.assert_awaited_once() + + +async def test_activate_promo_group_group_not_found( + monkeypatch, + sample_user, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test activation when promo group doesn't exist (deleted/invalid) + + Scenario: + - Promocode references non-existent promo_group_id + - get_promo_group_by_id returns None + - Warning is logged but activation doesn't fail + - Promocode effects still apply (graceful degradation) + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + # Promo group NOT FOUND + get_promo_group_mock = AsyncMock(return_value=None) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Assertions + assert result["success"] is True # Still succeeds! + + # Verify promo group was attempted to fetch + get_promo_group_mock.assert_awaited_once() + + # Verify promo group assignment was NOT called (because group not found) + add_promo_group_mock.assert_not_awaited() + + # But usage was still recorded + create_usage_mock.assert_awaited_once() + + +async def test_activate_promo_group_assignment_error( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test activation when promo group assignment fails + + Scenario: + - add_user_to_promo_group raises exception + - Error is logged but activation doesn't fail + - Promocode usage is still recorded (graceful degradation) + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + # add_user_to_promo_group RAISES EXCEPTION + add_promo_group_mock = AsyncMock(side_effect=Exception("Database error")) + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Assertions + assert result["success"] is True # Still succeeds! + + # Verify promo group assignment was attempted + add_promo_group_mock.assert_awaited_once() + + # But usage was still recorded + create_usage_mock.assert_awaited_once() + + +async def test_activate_promo_group_assigned_by_value( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test that assigned_by parameter is correctly set to 'promocode' + + Scenario: + - Verify add_user_to_promo_group is called with assigned_by="promocode" + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Verify assigned_by="promocode" + add_promo_group_mock.assert_awaited_once_with( + mock_db_session, + sample_user.id, + sample_promo_group.id, + assigned_by="promocode" # Critical assertion + ) + + +async def test_activate_promo_group_description_includes_group_name( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test that result description includes promo group name + + Scenario: + - When promo group is assigned, description should include group name + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Verify description includes promo group name + assert "Назначена промогруппа: Test VIP Group" in result["description"] + + +async def test_promocode_data_includes_promo_group_id( + monkeypatch, + sample_user, + sample_promo_group, + sample_promocode_promo_group, + mock_db_session, +): + """ + Test that returned promocode data includes promo_group_id + + Scenario: + - Verify result["promocode"]["promo_group_id"] is present + """ + sample_promocode_promo_group.is_valid = True + + # Mock CRUD functions + get_user_mock = AsyncMock(return_value=sample_user) + monkeypatch.setattr( + 'app.services.promocode_service.get_user_by_id', + get_user_mock + ) + + get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promocode_by_code', + get_promocode_mock + ) + + check_usage_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.check_user_promocode_usage', + check_usage_mock + ) + + get_promo_group_mock = AsyncMock(return_value=sample_promo_group) + monkeypatch.setattr( + 'app.services.promocode_service.get_promo_group_by_id', + get_promo_group_mock + ) + + has_promo_group_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + 'app.services.promocode_service.has_user_promo_group', + has_promo_group_mock + ) + + add_promo_group_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.add_user_to_promo_group', + add_promo_group_mock + ) + + create_usage_mock = AsyncMock() + monkeypatch.setattr( + 'app.services.promocode_service.create_promocode_use', + create_usage_mock + ) + + # Execute + service = PromoCodeService() + result = await service.activate_promocode( + mock_db_session, + sample_user.id, + "VIPGROUP" + ) + + # Verify promocode data structure + assert "promocode" in result + assert "promo_group_id" in result["promocode"] + assert result["promocode"]["promo_group_id"] == sample_promo_group.id diff --git a/tests/services/test_referral_service.py b/tests/services/test_referral_service.py index c59c5bd5..76e709ad 100644 --- a/tests/services/test_referral_service.py +++ b/tests/services/test_referral_service.py @@ -12,7 +12,6 @@ if str(ROOT_DIR) not in sys.path: from app.services import referral_service # noqa: E402 -@pytest.mark.asyncio async def test_commission_accrues_before_minimum_first_topup(monkeypatch): user = SimpleNamespace( id=1, diff --git a/tests/services/test_remnawave_service_sync.py b/tests/services/test_remnawave_service_sync.py index 8035db6f..1acb9804 100644 --- a/tests/services/test_remnawave_service_sync.py +++ b/tests/services/test_remnawave_service_sync.py @@ -66,7 +66,6 @@ def test_deduplicate_ignores_records_without_expire_date(): assert deduplicated[telegram_id] is valid -@pytest.mark.asyncio async def test_get_or_create_user_handles_unique_violation(monkeypatch): service = _create_service() db = AsyncMock() @@ -97,7 +96,6 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch): rollback_mock.assert_awaited() -@pytest.mark.asyncio async def test_get_or_create_user_creates_new(monkeypatch): service = _create_service() db = AsyncMock() diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index a4d464d6..1137f1e9 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -25,7 +25,6 @@ class DummyTexts: return f"{value / 100:.0f} ₽" -@pytest.mark.asyncio async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) @@ -185,7 +184,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): admin_service_mock.send_subscription_purchase_notification.assert_awaited() -@pytest.mark.asyncio async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) @@ -298,3 +296,379 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): bot.send_message.assert_awaited() service_mock.update_remnawave_user.assert_awaited() create_transaction_mock.assert_awaited() + + +@pytest.mark.asyncio +async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch): + """Тест: триал сохраняется, если не хватает денег для автопокупки""" + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + + subscription = MagicMock() + subscription.id = 123 + subscription.is_trial = True # Триальная подписка! + subscription.status = "active" + subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня + subscription.device_limit = 1 + subscription.traffic_limit_gb = 10 + subscription.connected_squads = [] + + user = MagicMock(spec=User) + user.id = 99 + user.telegram_id = 9999 + # ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243), + # но subtract_user_balance вернёт False (симуляция неудачи списания) + user.balance_kopeks = 60_000 + user.language = "ru" + user.subscription = subscription + + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": 30, + "total_price": 50_000, + "description": "Продление на 30 дней", + "device_limit": 1, + "traffic_limit_gb": 100, + "squad_uuid": None, + "consume_promo_offer": False, + } + + # Mock: недостаточно денег, списание не удалось + subtract_mock = AsyncMock(return_value=False) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.subtract_user_balance", + subtract_mock, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + AsyncMock(return_value=cart_data), + ) + + db_session = AsyncMock(spec=AsyncSession) + bot = AsyncMock() + + result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) + + # Проверки + assert result is False # Автопокупка не удалась + assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН! + subtract_mock.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch): + """Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления""" + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + + subscription = MagicMock() + subscription.id = 456 + subscription.is_trial = True # Триальная подписка! + subscription.status = "active" + subscription.end_date = datetime.utcnow() + timedelta(days=1) + subscription.device_limit = 1 + subscription.traffic_limit_gb = 10 + subscription.connected_squads = [] + + user = MagicMock(spec=User) + user.id = 88 + user.telegram_id = 8888 + user.balance_kopeks = 200_000 # Достаточно денег + user.language = "ru" + user.subscription = subscription + + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": 30, + "total_price": 100_000, + "description": "Продление на 30 дней", + "device_limit": 2, + "traffic_limit_gb": 500, + "squad_uuid": None, + "consume_promo_offer": False, + } + + # Mock: деньги списались успешно + subtract_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.subtract_user_balance", + subtract_mock, + ) + + # Mock: продление успешно + async def extend_stub(db, current_subscription, days): + current_subscription.end_date = current_subscription.end_date + timedelta(days=days) + return current_subscription + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.extend_subscription", + extend_stub, + ) + + create_transaction_mock = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.create_transaction", + create_transaction_mock, + ) + + service_mock = MagicMock() + service_mock.update_remnawave_user = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.SubscriptionService", + lambda: service_mock, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + AsyncMock(return_value=cart_data), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + AsyncMock(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + AsyncMock(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.get_texts", + lambda lang: DummyTexts(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_period_description", + lambda days, lang: f"{days} дней", + ) + # ИСПРАВЛЕНО: Добавлен мок для format_local_datetime + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_local_datetime", + lambda dt, fmt: dt.strftime(fmt) if dt else "", + ) + + admin_service_mock = MagicMock() + admin_service_mock.send_subscription_extension_notification = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.AdminNotificationService", + lambda bot: admin_service_mock, + ) + + db_session = AsyncMock(spec=AsyncSession) + db_session.commit = AsyncMock() # Важно! Отслеживаем commit + db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh + bot = AsyncMock() + + result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) + + # Проверки + assert result is True # Автопокупка успешна + assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН! + assert subscription.status == "active" + db_session.commit.assert_awaited() # Commit был вызван + + +@pytest.mark.asyncio +async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): + """Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription""" + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + + subscription = MagicMock() + subscription.id = 789 + subscription.is_trial = True # Триальная подписка! + subscription.status = "active" + subscription.end_date = datetime.utcnow() + timedelta(days=3) + subscription.device_limit = 1 + subscription.traffic_limit_gb = 10 + subscription.connected_squads = [] + + user = MagicMock(spec=User) + user.id = 77 + user.telegram_id = 7777 + user.balance_kopeks = 200_000 # Достаточно денег + user.language = "ru" + user.subscription = subscription + + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": 30, + "total_price": 100_000, + "description": "Продление на 30 дней", + "device_limit": 1, + "traffic_limit_gb": 100, + "squad_uuid": None, + "consume_promo_offer": False, + } + + # Mock: деньги списались успешно + subtract_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.subtract_user_balance", + subtract_mock, + ) + + # Mock: extend_subscription выбрасывает ошибку! + async def extend_error(db, current_subscription, days): + raise Exception("Database connection error") + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.extend_subscription", + extend_error, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + AsyncMock(return_value=cart_data), + ) + + # ИСПРАВЛЕНО: Добавлены недостающие моки + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.get_texts", + lambda lang: DummyTexts(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_period_description", + lambda days, lang: f"{days} дней", + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_local_datetime", + lambda dt, fmt: dt.strftime(fmt) if dt else "", + ) + + db_session = AsyncMock(spec=AsyncSession) + db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback + db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh + bot = AsyncMock() + + result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) + + # Проверки + assert result is False # Автопокупка не удалась + assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН! + db_session.rollback.assert_awaited() # ROLLBACK БЫЛ ВЫЗВАН! + + +@pytest.mark.asyncio +async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): + """Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True""" + monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено! + + now = datetime.utcnow() + trial_end = now + timedelta(days=2) # Осталось 2 дня триала + + subscription = MagicMock() + subscription.id = 321 + subscription.is_trial = True + subscription.status = "active" + subscription.end_date = trial_end + subscription.start_date = now - timedelta(days=1) # Триал начался вчера + subscription.device_limit = 1 + subscription.traffic_limit_gb = 10 + subscription.connected_squads = [] + + user = MagicMock(spec=User) + user.id = 66 + user.telegram_id = 6666 + user.balance_kopeks = 200_000 + user.language = "ru" + user.subscription = subscription + + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": 30, # Покупает 30 дней + "total_price": 100_000, + "description": "Продление на 30 дней", + "device_limit": 1, + "traffic_limit_gb": 100, + "squad_uuid": None, + "consume_promo_offer": False, + } + + subtract_mock = AsyncMock(return_value=True) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.subtract_user_balance", + subtract_mock, + ) + + # Mock: extend_subscription с логикой переноса бонусных дней + # Имитируем нашу новую логику из extend_subscription() + async def extend_with_bonus(db, current_subscription, days): + # Вычисляем бонусные дни (как в нашем коде) + bonus_days = 0 + if current_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if current_subscription.end_date and current_subscription.end_date > now: + remaining = current_subscription.end_date - now + if remaining.total_seconds() > 0: + bonus_days = max(0, remaining.days) + + total_days = days + bonus_days + current_subscription.end_date = current_subscription.end_date + timedelta(days=total_days) + return current_subscription + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.extend_subscription", + extend_with_bonus, + ) + + create_transaction_mock = AsyncMock(return_value=MagicMock()) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.create_transaction", + create_transaction_mock, + ) + + service_mock = MagicMock() + service_mock.update_remnawave_user = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.SubscriptionService", + lambda: service_mock, + ) + + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + AsyncMock(return_value=cart_data), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + AsyncMock(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + AsyncMock(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.get_texts", + lambda lang: DummyTexts(), + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_period_description", + lambda days, lang: f"{days} дней", + ) + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.format_local_datetime", + lambda dt, fmt: dt.strftime(fmt), + ) + + admin_service_mock = MagicMock() + admin_service_mock.send_subscription_extension_notification = AsyncMock() + monkeypatch.setattr( + "app.services.subscription_auto_purchase_service.AdminNotificationService", + lambda bot: admin_service_mock, + ) + + db_session = AsyncMock(spec=AsyncSession) + db_session.commit = AsyncMock() + db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh + bot = AsyncMock() + + result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) + + # Проверки + assert result is True + assert subscription.is_trial is False # Триал конвертирован + + # Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных) + # end_date должна быть примерно на 32 дня от оригинального trial_end + expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2) + actual_delta = (subscription.end_date - trial_end).days + assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}" diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py index 81ed9104..243013f2 100644 --- a/tests/services/test_system_settings_env_priority.py +++ b/tests/services/test_system_settings_env_priority.py @@ -12,7 +12,6 @@ from app.config import settings from app.services.system_settings_service import bot_configuration_service -@pytest.mark.asyncio async def test_env_override_prevents_set_value(monkeypatch): bot_configuration_service.initialize_definitions() @@ -45,7 +44,6 @@ async def test_env_override_prevents_set_value(monkeypatch): assert not bot_configuration_service.has_override("SUPPORT_USERNAME") -@pytest.mark.asyncio async def test_env_override_prevents_reset_value(monkeypatch): bot_configuration_service.initialize_definitions() @@ -77,7 +75,6 @@ async def test_env_override_prevents_reset_value(monkeypatch): assert not bot_configuration_service.has_override("SUPPORT_USERNAME") -@pytest.mark.asyncio async def test_initialize_skips_db_value_for_env_override(monkeypatch): bot_configuration_service.initialize_definitions() @@ -130,7 +127,6 @@ async def test_initialize_skips_db_value_for_env_override(monkeypatch): assert not bot_configuration_service.has_override("SUPPORT_USERNAME") -@pytest.mark.asyncio async def test_set_value_applies_without_env_override(monkeypatch): bot_configuration_service.initialize_definitions() diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py index ce0064d1..e4bfc5a8 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -54,7 +54,6 @@ def mock_state(): state.clear = AsyncMock() return state -@pytest.mark.asyncio async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db): """Тест сохранения корзины и перенаправления к пополнению""" # Мокаем все зависимости @@ -102,7 +101,6 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, # mock_callback_query.answer не должен быть вызван mock_callback_query.answer.assert_not_called() -@pytest.mark.asyncio async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с достаточным балансом""" # Подготовим данные корзины @@ -153,7 +151,6 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc mock_callback_query.answer.assert_called_once() -@pytest.mark.asyncio async def test_return_to_saved_cart_skips_edit_when_message_matches( mock_callback_query, mock_state, @@ -224,7 +221,6 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches( mock_cart_service.save_user_cart.assert_not_called() -@pytest.mark.asyncio async def test_return_to_saved_cart_normalizes_devices_when_disabled( mock_callback_query, mock_state, @@ -299,7 +295,6 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled( mock_callback_query.answer.assert_called_once() -@pytest.mark.asyncio async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с недостаточным балансом""" # Подготовим данные корзины @@ -347,7 +342,6 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock # (ответ отправляется через return до вызова callback.answer()) mock_callback_query.answer.assert_not_called() -@pytest.mark.asyncio async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Тест очистки сохраненной корзины""" # Мокаем все зависимости @@ -369,7 +363,6 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock # Проверяем, что вызван answer mock_callback_query.answer.assert_called_once() -@pytest.mark.asyncio async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Отмена покупки должна очищать сохраненную корзину""" mock_clear_draft = AsyncMock() diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py index 21de03c2..56fe8543 100644 --- a/tests/test_user_cart_service.py +++ b/tests/test_user_cart_service.py @@ -34,7 +34,6 @@ def user_cart_service(mock_redis): service.redis_client = mock_redis return service -@pytest.mark.asyncio async def test_save_user_cart(user_cart_service, mock_redis): """Тест сохранения корзины пользователя""" user_id = 12345 @@ -52,7 +51,6 @@ async def test_save_user_cart(user_cart_service, mock_redis): assert f"user_cart:{user_id}" in mock_redis.storage assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"]) -@pytest.mark.asyncio async def test_get_user_cart(user_cart_service, mock_redis): """Тест получения корзины пользователя""" user_id = 12345 @@ -72,7 +70,6 @@ async def test_get_user_cart(user_cart_service, mock_redis): assert result == cart_data -@pytest.mark.asyncio async def test_get_user_cart_not_found(user_cart_service): """Тест получения несуществующей корзины пользователя""" user_id = 99999 @@ -81,7 +78,6 @@ async def test_get_user_cart_not_found(user_cart_service): assert result is None -@pytest.mark.asyncio async def test_delete_user_cart(user_cart_service, mock_redis): """Тест удаления корзины пользователя""" user_id = 12345 @@ -103,7 +99,6 @@ async def test_delete_user_cart(user_cart_service, mock_redis): assert result is True assert f"user_cart:{user_id}" not in mock_redis.storage -@pytest.mark.asyncio async def test_delete_user_cart_not_found(user_cart_service): """Тест удаления несуществующей корзины пользователя""" user_id = 99999 @@ -113,7 +108,6 @@ async def test_delete_user_cart_not_found(user_cart_service): assert result is False -@pytest.mark.asyncio async def test_has_user_cart(user_cart_service, mock_redis): """Тест проверки наличия корзины пользователя""" user_id = 12345 @@ -136,7 +130,6 @@ async def test_has_user_cart(user_cart_service, mock_redis): result = await user_cart_service.has_user_cart(user_id) assert result is True -@pytest.mark.asyncio async def test_has_user_cart_not_found(user_cart_service): """Тест проверки отсутствия корзины пользователя""" user_id = 99999 diff --git a/tests/utils/test_pricing_utils.py b/tests/utils/test_pricing_utils.py new file mode 100644 index 00000000..dbd03d57 --- /dev/null +++ b/tests/utils/test_pricing_utils.py @@ -0,0 +1,405 @@ +""" +Тесты для утилит ценообразования и форматирования цен. + +Этот модуль тестирует функции из app/utils/pricing_utils.py и app/localization/texts.py, +особенно функции отображения цен со скидками на кнопках подписки. +""" + +import pytest +from unittest.mock import patch, MagicMock +from typing import Dict, Any + +from app.utils.pricing_utils import format_period_option_label +from app.localization.texts import _build_dynamic_values + + +class TestFormatPeriodOptionLabel: + """Тесты для функции format_period_option_label.""" + + def test_format_with_price_only_no_discount(self) -> None: + """Цена без скидки должна отображаться в простом формате.""" + result = format_period_option_label("📅 30 дней", 99000) + assert result == "📅 30 дней - 990 ₽" + + def test_format_with_discount_shows_strikethrough(self) -> None: + """Цена со скидкой должна показывать зачёркнутую оригинальную цену.""" + result = format_period_option_label( + "📅 30 дней", + price=69300, + original_price=99000, + discount_percent=30 + ) + assert result == "📅 30 дней - 990 ₽ 693 ₽ (-30%)" + + def test_format_with_zero_price_returns_label_only(self) -> None: + """Нулевая цена должна возвращать только метку без цены.""" + result = format_period_option_label("📅 30 дней", 0) + assert result == "📅 30 дней" + + def test_format_with_negative_price_returns_label_only(self) -> None: + """Отрицательная цена должна возвращать только метку.""" + result = format_period_option_label("📅 30 дней", -1000) + assert result == "📅 30 дней" + + def test_format_with_zero_discount_percent_shows_simple_price(self) -> None: + """Нулевая скидка должна отображать простую цену без зачёркивания.""" + result = format_period_option_label( + "📅 30 дней", + price=99000, + original_price=99000, + discount_percent=0 + ) + assert result == "📅 30 дней - 990 ₽" + + def test_format_with_original_price_equal_to_final_shows_simple(self) -> None: + """Если оригинальная цена равна финальной, показывать простой формат.""" + result = format_period_option_label( + "📅 30 дней", + price=99000, + original_price=99000, + discount_percent=10 # Указана скидка, но цены равны + ) + assert result == "📅 30 дней - 990 ₽" + + def test_format_with_original_price_less_than_final_shows_simple(self) -> None: + """Если оригинальная цена меньше финальной (некорректно), показывать простой формат.""" + result = format_period_option_label( + "📅 30 дней", + price=99000, + original_price=50000, + discount_percent=10 + ) + assert result == "📅 30 дней - 990 ₽" + + @pytest.mark.parametrize( + "label,price,original,discount,expected", + [ + # Базовые случаи + ("📅 14 дней", 50000, 0, 0, "📅 14 дней - 500 ₽"), + ("📅 30 дней", 99000, 0, 0, "📅 30 дней - 990 ₽"), + ("📅 360 дней", 899000, 0, 0, "📅 360 дней - 8990 ₽"), + + # Со скидками + ("📅 30 дней", 69300, 99000, 30, "📅 30 дней - 990 ₽ 693 ₽ (-30%)"), + ("📅 90 дней", 188300, 269000, 30, "📅 90 дней - 2690 ₽ 1883 ₽ (-30%)"), + ("📅 360 дней", 629300, 899000, 30, "📅 360 дней - 8990 ₽ 6293 ₽ (-30%)"), + + # Разные проценты скидок + ("📅 30 дней", 89100, 99000, 10, "📅 30 дней - 990 ₽ 891 ₽ (-10%)"), + ("📅 30 дней", 49500, 99000, 50, "📅 30 дней - 990 ₽ 495 ₽ (-50%)"), + + # Цены с копейками + ("📅 7 дней", 12345, 0, 0, "📅 7 дней - 123.45 ₽"), + ("📅 7 дней", 12350, 0, 0, "📅 7 дней - 123.5 ₽"), + ], + ) + def test_format_various_scenarios( + self, + label: str, + price: int, + original: int, + discount: int, + expected: str + ) -> None: + """Различные сценарии форматирования должны работать корректно.""" + result = format_period_option_label(label, price, original, discount) + assert result == expected + + def test_format_with_100_percent_discount(self) -> None: + """100% скидка должна корректно отображаться.""" + result = format_period_option_label( + "📅 30 дней", + price=0, + original_price=99000, + discount_percent=100 + ) + # Цена 0, поэтому возвращается только label + assert result == "📅 30 дней" + + def test_format_preserves_label_emojis(self) -> None: + """Эмодзи в метке должны сохраняться.""" + result = format_period_option_label("🔥 📅 360 дней 🔥", 899000) + assert result == "🔥 📅 360 дней 🔥 - 8990 ₽" + + def test_format_with_large_prices(self) -> None: + """Большие цены должны корректно форматироваться.""" + result = format_period_option_label( + "📅 720 дней", + price=150000000, # 1,500,000 рублей + original_price=200000000, + discount_percent=25 + ) + assert result == "📅 720 дней - 2000000 ₽ 1500000 ₽ (-25%)" + + def test_format_with_small_prices_kopeks(self) -> None: + """Маленькие цены с копейками должны корректно отображаться.""" + result = format_period_option_label( + "📅 1 день", + price=5050, # 50.50 рублей + original_price=10000, + discount_percent=50 + ) + assert result == "📅 1 день - 100 ₽ 50.5 ₽ (-50%)" + + def test_format_without_optional_params_uses_defaults(self) -> None: + """Вызов без опциональных параметров должен использовать значения по умолчанию.""" + result = format_period_option_label("📅 30 дней", 99000) + assert result == "📅 30 дней - 990 ₽" + + +class TestBuildDynamicValues: + """Тесты для функции _build_dynamic_values из texts.py.""" + + @patch('app.localization.texts.settings') + def test_russian_language_generates_period_keys(self, mock_settings: MagicMock) -> None: + """Русский язык должен генерировать все ключи периодов.""" + # Настройка моков + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.get_base_promo_group_period_discount.return_value = 0 + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Мок для traffic цен + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + assert "PERIOD_14_DAYS" in result + assert "PERIOD_30_DAYS" in result + assert "PERIOD_60_DAYS" in result + assert "PERIOD_90_DAYS" in result + assert "PERIOD_180_DAYS" in result + assert "PERIOD_360_DAYS" in result + + @patch('app.localization.texts.settings') + def test_english_language_generates_period_keys(self, mock_settings: MagicMock) -> None: + """Английский язык должен генерировать все ключи периодов.""" + # Настройка моков + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.get_base_promo_group_period_discount.return_value = 0 + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Мок для traffic цен + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("en-US") + + assert "PERIOD_14_DAYS" in result + assert "PERIOD_30_DAYS" in result + assert "PERIOD_360_DAYS" in result + # Проверяем, что используется "days" а не "дней" + assert "days" in result["PERIOD_30_DAYS"] + + @patch('app.localization.texts.settings') + @patch('app.utils.pricing_utils.apply_percentage_discount') + def test_period_with_discount_shows_strikethrough( + self, + mock_apply_discount: MagicMock, + mock_settings: MagicMock + ) -> None: + """Период со скидкой должен показывать зачёркнутую цену.""" + # Настройка моков + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.get_base_promo_group_period_discount.return_value = 30 + mock_apply_discount.return_value = (69300, 29700) # 30% скидка + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Остальные цены + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + # Проверяем, что есть зачёркивание и процент скидки + assert "990 ₽" in result["PERIOD_30_DAYS"] + assert "(-30%)" in result["PERIOD_30_DAYS"] + + @patch('app.localization.texts.settings') + def test_period_360_with_discount_has_fire_emojis(self, mock_settings: MagicMock) -> None: + """Период 360 дней со скидкой должен иметь огоньки 🔥.""" + # Настройка моков для 360 дней со скидкой + mock_settings.PRICE_360_DAYS = 899000 + + def get_discount(period_days: int) -> int: + return 30 if period_days == 360 else 0 + + mock_settings.get_base_promo_group_period_discount.side_effect = get_discount + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Остальные цены + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + # Проверяем наличие огоньков + assert result["PERIOD_360_DAYS"].startswith("🔥") + assert result["PERIOD_360_DAYS"].endswith("🔥") + assert result["PERIOD_360_DAYS"].count("🔥") == 2 + + @patch('app.localization.texts.settings') + def test_period_360_without_discount_no_fire_emojis(self, mock_settings: MagicMock) -> None: + """Период 360 дней без скидки НЕ должен иметь огоньки 🔥.""" + # Настройка моков для 360 дней БЕЗ скидки + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.get_base_promo_group_period_discount.return_value = 0 # Нет скидки + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Остальные цены + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + # Проверяем отсутствие огоньков + assert "🔥" not in result["PERIOD_360_DAYS"] + # Но должна быть просто цена + assert "8990 ₽" in result["PERIOD_360_DAYS"] + + @patch('app.localization.texts.settings') + def test_other_periods_never_have_fire_emojis(self, mock_settings: MagicMock) -> None: + """Другие периоды (не 360) никогда не должны иметь огоньки, даже со скидкой.""" + # Настройка моков - 30 дней со скидкой + mock_settings.PRICE_30_DAYS = 99000 + + def get_discount(period_days: int) -> int: + return 30 if period_days == 30 else 0 + + mock_settings.get_base_promo_group_period_discount.side_effect = get_discount + mock_settings.format_price = lambda x: f"{x // 100} ₽" + + # Остальные цены + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + # 30 дней со скидкой не должно иметь огоньков + assert "🔥" not in result["PERIOD_30_DAYS"] + # Но должна быть скидка + assert "" in result["PERIOD_30_DAYS"] + + @patch('app.localization.texts.settings') + def test_returns_empty_dict_for_unknown_language(self, mock_settings: MagicMock) -> None: + """Неизвестный язык должен возвращать пустой словарь.""" + result = _build_dynamic_values("fr-FR") # Французский не поддерживается + assert result == {} + + @patch('app.localization.texts.settings') + def test_language_code_extraction_works(self, mock_settings: MagicMock) -> None: + """Должна корректно извлекаться языковая часть из locale.""" + # Настройка моков + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.get_base_promo_group_period_discount.return_value = 0 + mock_settings.format_price = lambda x: f"{x // 100} ₽" + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + # Тест с полным locale кодом + result1 = _build_dynamic_values("ru-RU") + result2 = _build_dynamic_values("ru") + result3 = _build_dynamic_values("RU-ru") + + # Все должны вернуть русские значения + assert "дней" in result1["PERIOD_30_DAYS"] + assert "дней" in result2["PERIOD_30_DAYS"] + assert "дней" in result3["PERIOD_30_DAYS"] + + @patch('app.localization.texts.settings') + def test_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None: + """Должны генерироваться не только периоды, но и ключи трафика.""" + # Настройка моков + mock_settings.PRICE_14_DAYS = 50000 + mock_settings.PRICE_30_DAYS = 99000 + mock_settings.PRICE_60_DAYS = 189000 + mock_settings.PRICE_90_DAYS = 269000 + mock_settings.PRICE_180_DAYS = 499000 + mock_settings.PRICE_360_DAYS = 899000 + mock_settings.get_base_promo_group_period_discount.return_value = 0 + mock_settings.format_price = lambda x: f"{x // 100} ₽" + mock_settings.PRICE_TRAFFIC_5GB = 10000 + mock_settings.PRICE_TRAFFIC_10GB = 20000 + mock_settings.PRICE_TRAFFIC_25GB = 30000 + mock_settings.PRICE_TRAFFIC_50GB = 40000 + mock_settings.PRICE_TRAFFIC_100GB = 50000 + mock_settings.PRICE_TRAFFIC_250GB = 60000 + mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 + + result = _build_dynamic_values("ru-RU") + + # Проверяем наличие ключей трафика + assert "TRAFFIC_5GB" in result + assert "TRAFFIC_10GB" in result + assert "TRAFFIC_UNLIMITED" in result + assert "SUPPORT_INFO" in result