diff --git a/.gitignore b/.gitignore index 93a0d52b..9bb24585 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Игнорируем все файлы и папки по умолчанию * +docker-compose.override.yml # Исключения: разрешаем только нужные файлы !.dockerignore diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d33c0f28 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: up up-follow down reload reload-follow test + +up: + docker compose up -d --build + +up-follow: + docker compose up --build + +down: + docker compose down + +reload: down up + +reload-follow: down up-follow + +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 ac24f461..59a611cb 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) @@ -66,7 +67,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) @@ -99,7 +100,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) @@ -833,7 +834,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), selectinload(User.referrer), ) .where(User.referred_by_id == user_id) @@ -940,7 +941,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), selectinload(User.referrer), ) .where( 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..e6080017 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,51 @@ 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 getattr(self, "promo_group", None) + + try: + # Сортируем по приоритету группы (убывание), затем по ID группы + # Используем getattr для защиты от ленивой загрузки + sorted_groups = sorted( + self.user_promo_groups, + key=lambda upg: ( + getattr(upg.promo_group, 'priority', 0) 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 + except Exception: + # Если возникла ошибка (например, ленивая загрузка), fallback на старую связь + pass + + # Fallback на старую связь если новая пустая или возникла ошибка + return getattr(self, "promo_group", None) + 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 +840,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..60341eaa 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: @@ -4536,12 +4603,12 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register( show_user_promo_group, - F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") + F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_") ) 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/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index 4456c09e..8ee1a3df 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -67,7 +67,7 @@ async def start_cryptobot_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 30af3682..37ac4b53 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -55,7 +55,7 @@ async def start_heleket_payment( if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_buttons = get_quick_amount_buttons(db_user.language) + quick_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_buttons: keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index c3e67023..75032ca9 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -8,6 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import BalanceStates from app.database.crud.user import add_user_balance +from app.utils.price_display import calculate_user_price, format_price_button +from app.utils.pricing_utils import format_period_description from app.database.crud.transaction import ( get_user_transactions, get_user_transactions_count, create_transaction @@ -27,34 +29,61 @@ logger = logging.getLogger(__name__) TRANSACTIONS_PER_PAGE = 10 -def get_quick_amount_buttons(language: str) -> list: +def get_quick_amount_buttons(language: str, user: User) -> list: + """ + Generate quick amount buttons with user-specific pricing and discounts. + + Args: + language: User's language for formatting + user: User object to calculate personalized discounts + + Returns: + List of button rows for inline keyboard + """ if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS: return [] - + + from app.localization.texts import get_texts + texts = get_texts(language) + buttons = [] periods = settings.get_available_subscription_periods() - - periods = periods[:6] - + periods = periods[:6] # Limit to 6 periods + for period in periods: price_attr = f"PRICE_{period}_DAYS" if hasattr(settings, price_attr): - price_kopeks = getattr(settings, price_attr) - price_rubles = price_kopeks // 100 - - callback_data = f"quick_amount_{price_kopeks}" - + base_price_kopeks = getattr(settings, price_attr) + + # Calculate price with user's promo group discount using unified system + price_info = calculate_user_price(user, base_price_kopeks, period, "period") + + callback_data = f"quick_amount_{price_info.final_price}" + + # Format button text with discount display + period_label = f"{period} дней" + + # For balance buttons, use simpler format without emoji and period label prefix + if price_info.has_discount: + button_text = ( + f"{texts.format_price(price_info.base_price)} ➜ " + f"{texts.format_price(price_info.final_price)} " + f"(-{price_info.discount_percent}%) • {period_label}" + ) + else: + button_text = f"{texts.format_price(price_info.final_price)} • {period_label}" + buttons.append( types.InlineKeyboardButton( - text=f"{price_rubles} ₽ ({period} дней)", + text=button_text, callback_data=callback_data ) ) - + keyboard_rows = [] for i in range(0, len(buttons), 2): keyboard_rows.append(buttons[i:i + 2]) - + return keyboard_rows diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index 128de578..840ac469 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -48,7 +48,7 @@ async def start_mulenpay_payment( if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index da713960..dcda2e38 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -264,7 +264,7 @@ async def start_pal24_payment( if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 078ba8fd..0dcf0031 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -42,7 +42,7 @@ async def start_stars_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index ddd7c5b4..259d559e 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -45,7 +45,7 @@ async def start_wata_payment( if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index 2b5601c7..607f62bd 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -48,7 +48,7 @@ async def start_yookassa_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -98,7 +98,7 @@ async def start_yookassa_sbp_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language) + quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index 0ae8897c..b2c1f3b0 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -187,7 +187,7 @@ async def handle_subscription_config_back( if current_state == SubscriptionStates.selecting_traffic.state: await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), - reply_markup=get_subscription_period_keyboard(db_user.language), + reply_markup=get_subscription_period_keyboard(db_user.language, db_user), parse_mode="HTML", ) await state.set_state(SubscriptionStates.selecting_period) @@ -202,7 +202,7 @@ async def handle_subscription_config_back( else: await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), - reply_markup=get_subscription_period_keyboard(db_user.language), + reply_markup=get_subscription_period_keyboard(db_user.language, db_user), parse_mode="HTML", ) await state.set_state(SubscriptionStates.selecting_period) @@ -277,7 +277,7 @@ async def _show_previous_configuration_step( await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), - reply_markup=get_subscription_period_keyboard(db_user.language), + reply_markup=get_subscription_period_keyboard(db_user.language, db_user), parse_mode="HTML", ) await state.set_state(SubscriptionStates.selecting_period) diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index c619913e..89bfcccf 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user( if user is None: return 0 - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() if promo_group is None: return 0 diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index bd04165e..9489f9ef 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -350,7 +350,7 @@ async def _build_subscription_period_prompt( if promo_offer_hint: lines.extend(["", promo_offer_hint]) - promo_text = _build_promo_group_discount_text( + promo_text = await _build_promo_group_discount_text( db_user, settings.get_available_subscription_periods(), texts=texts, diff --git a/app/handlers/subscription/promo.py b/app/handlers/subscription/promo.py index ade900ef..6fefd6ac 100644 --- a/app/handlers/subscription/promo.py +++ b/app/handlers/subscription/promo.py @@ -88,12 +88,12 @@ async def _get_promo_offer_hint( ) -> Optional[str]: return await build_promo_offer_hint(db, db_user, texts, percent) -def _build_promo_group_discount_text( +async def _build_promo_group_discount_text( db_user: User, periods: Optional[List[int]] = None, texts=None, ) -> str: - promo_group = getattr(db_user, "promo_group", None) + promo_group = db_user.get_primary_promo_group() if not promo_group: return "" diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 8350d095..33a282f9 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -104,6 +104,7 @@ from app.utils.pricing_utils import ( format_period_description, apply_percentage_discount, ) +from app.utils.price_display import PriceInfo, format_price_text, calculate_user_price from app.utils.subscription_utils import ( convert_subscription_link_to_happ_scheme, get_display_subscription_link, @@ -446,7 +447,7 @@ async def show_trial_offer( trial_text = texts.TRIAL_AVAILABLE.format( days=settings.TRIAL_DURATION_DAYS, - traffic=settings.TRIAL_TRAFFIC_LIMIT_GB, + traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB), devices_line=devices_line, server_name=trial_server_name ) @@ -647,7 +648,7 @@ async def start_subscription_purchase( ): texts = get_texts(db_user.language) - keyboard = get_subscription_period_keyboard(db_user.language) + keyboard = get_subscription_period_keyboard(db_user.language, db_user) prompt_text = await _build_subscription_period_prompt(db_user, texts, db) await _edit_message_text_or_caption( @@ -931,25 +932,21 @@ async def handle_extend_subscription( months_in_period = calculate_months_from_days(days) from app.config import PERIOD_PRICES - base_price_original = PERIOD_PRICES.get(days, 0) - period_discount_percent = db_user.get_promo_discount("period", days) - base_price, _ = apply_percentage_discount( - base_price_original, - period_discount_percent, - ) + # 1. Calculate period price with promo group discount using unified system + base_price_original = PERIOD_PRICES.get(days, 0) + period_price_info = calculate_user_price(db_user, base_price_original, days, "period") + + # 2. Calculate servers price with promo group discount servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db, promo_group_id=db_user.promo_group_id, ) - servers_discount_percent = db_user.get_promo_discount( - "servers", - days, - ) - servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 - total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period + servers_total_base = servers_price_per_month * months_in_period + servers_price_info = calculate_user_price(db_user, servers_total_base, days, "servers") + # 3. Calculate devices price with promo group discount device_limit = subscription.device_limit if device_limit is None: if settings.is_devices_selection_enabled(): @@ -963,31 +960,34 @@ async def handle_extend_subscription( additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = db_user.get_promo_discount( - "devices", - days, - ) - devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 - total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period + devices_total_base = devices_price_per_month * months_in_period + devices_price_info = calculate_user_price(db_user, devices_total_base, days, "devices") + # 4. Calculate traffic price with promo group discount traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - traffic_discount_percent = db_user.get_promo_discount( - "traffic", - days, - ) - traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 - total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period + traffic_total_base = traffic_price_per_month * months_in_period + traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, "traffic") + # 5. Calculate ORIGINAL price (before ALL discounts) total_original_price = ( - base_price_original - + servers_price_per_month * months_in_period - + devices_price_per_month * months_in_period - + traffic_price_per_month * months_in_period + period_price_info.base_price + + servers_price_info.base_price + + devices_price_info.base_price + + traffic_price_info.base_price ) - price = base_price + total_servers_price + total_devices_price + total_traffic_price - promo_component = _apply_promo_offer_discount(db_user, price) + # 6. Sum prices with promo group discounts applied + total_price = ( + period_price_info.final_price + + servers_price_info.final_price + + devices_price_info.final_price + + traffic_price_info.final_price + ) + # 7. Apply promo offer discount on top of promo group discounts + promo_component = _apply_promo_offer_discount(db_user, total_price) + + # Store: original = price before discounts, final = price with all discounts renewal_prices[days] = { "final": promo_component["discounted"], "original": total_original_price, @@ -1018,23 +1018,27 @@ async def handle_extend_subscription( final_price = price_info original_price = final_price - has_discount = original_price > final_price - period_display = format_period_description(days, db_user.language) - if has_discount: - prices_text += ( - "📅 " - f"{period_display} - {texts.format_price(original_price)} " - f"{texts.format_price(final_price)}\n" - ) - else: - prices_text += ( - "📅 " - f"{period_display} - {texts.format_price(final_price)}\n" - ) + # Calculate discount percentage for PriceInfo + discount_percent = 0 + if original_price > final_price and original_price > 0: + discount_percent = ((original_price - final_price) * 100) // original_price - promo_discounts_text = _build_promo_group_discount_text( + # Create PriceInfo and format text using unified system + price_info_obj = PriceInfo( + base_price=original_price, + final_price=final_price, + discount_percent=discount_percent + ) + + prices_text += format_price_text( + period_label=period_display, + price_info=price_info_obj, + format_price_func=texts.format_price + ) + "\n" + + promo_discounts_text = await _build_promo_group_discount_text( db_user, available_periods, texts=texts, 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..a522d3ca 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -13,6 +13,7 @@ from app.utils.pricing_utils import ( format_period_description, apply_percentage_discount, ) +from app.utils.price_display import PriceInfo, format_price_button from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, @@ -863,36 +864,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) -def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_subscription_period_keyboard( + language: str = DEFAULT_LANGUAGE, + user: Optional[User] = None +) -> InlineKeyboardMarkup: + """ + Generate subscription period selection keyboard with personalized pricing. + + Args: + language: User's language code + user: User object for personalized discounts (None = default discounts) + + Returns: + InlineKeyboardMarkup with period buttons showing personalized prices + """ + from app.utils.price_display import calculate_user_price + texts = get_texts(language) keyboard = [] - + available_periods = settings.get_available_subscription_periods() - - period_texts = { - 14: texts.PERIOD_14_DAYS, - 30: texts.PERIOD_30_DAYS, - 60: texts.PERIOD_60_DAYS, - 90: texts.PERIOD_90_DAYS, - 180: texts.PERIOD_180_DAYS, - 360: texts.PERIOD_360_DAYS - } - + for days in available_periods: - if days in period_texts: - keyboard.append([ - InlineKeyboardButton( - text=period_texts[days], - callback_data=f"period_{days}" - ) - ]) - + # Get base price for this period + base_price = PERIOD_PRICES.get(days, 0) + + # Calculate personalized price with user's discounts + price_info = calculate_user_price(user, base_price, days, "period") + + # Format period description + period_display = format_period_description(days, language) + + # Format button text with discount display + button_text = format_price_button( + period_label=period_display, + price_info=price_info, + format_price_func=texts.format_price, + emphasize=False, + add_exclamation=False + ) + + keyboard.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"period_{days}" + ) + ]) + # Кнопка "Простая покупка" была убрана из выбора периода подписки - + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") ]) - + return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1408,27 +1432,8 @@ def _get_days_word(days: int) -> str: -def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: - texts = get_texts(language) - keyboard = [] - - periods = [ - (14, texts.PERIOD_14_DAYS), - (30, texts.PERIOD_30_DAYS), - (60, texts.PERIOD_60_DAYS), - (90, texts.PERIOD_90_DAYS) - ] - - for days, text in periods: - keyboard.append([ - InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}") - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) +# Deprecated: get_extend_subscription_keyboard() was removed. +# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing. def get_add_traffic_keyboard( @@ -1965,15 +1970,39 @@ 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) + + # Create PriceInfo from already calculated prices + # Note: original_price and final_price are calculated in the handler + discount_percent = 0 + if original_price > final_price and original_price > 0: + discount_percent = ((original_price - final_price) * 100) // original_price + + price_info_obj = PriceInfo( + base_price=original_price, + final_price=final_price, + discount_percent=discount_percent + ) + + # Format button using unified system + button_text = format_price_button( + period_label=period_display, + price_info=price_info_obj, + format_price_func=texts.format_price, + emphasize=False, + add_exclamation=False + ) + 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..e9fb1c0e 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:", @@ -1383,7 +1395,7 @@ "TRIAL_ACTIVATED": "🎉 Trial subscription activated!", "TRIAL_ACTIVATE_BUTTON": "🎁 Activate", "TRIAL_ALREADY_USED": "❌ The trial subscription has already been used", - "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic} GB{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", + "TRIAL_AVAILABLE": "\n🎁 Trial subscription\n\nYou can get a free trial plan:\n\n⏰ Duration: {days} days\n📈 Traffic: {traffic}{devices_line}\n🌍 Server: {server_name}\n\nActivate the trial subscription?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Devices: {devices} pcs", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Access paused\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.", "TRIAL_ENDING_SOON": "\n🎁 The trial subscription is ending soon!\n\nYour trial expires in a few hours.\n\n💎 Don't want to lose VPN access?\nSwitch to the full subscription!\n\n🔥 Special offer:\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index fc188245..4adaadd9 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": "Доступные категории:", @@ -1403,7 +1415,7 @@ "TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!", "TRIAL_ACTIVATE_BUTTON": "🎁 Активировать", "TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована", - "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic} ГБ{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", + "TRIAL_AVAILABLE": "\n🎁 Тестовая подписка\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ Период: {days} дней\n📈 Трафик: {traffic}{devices_line}\n🌍 Сервер: {server_name}\n\nАктивировать тестовую подписку?\n", "TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 Устройства: {devices} шт.", "TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 Доступ приостановлен\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.", "TRIAL_ENDING_SOON": "\n🎁 Тестовая подписка скоро закончится!\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 Не хотите остаться без VPN?\nПереходите на полную подписку!\n\n🔥 Специальное предложение:\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n", diff --git a/app/localization/texts.py b/app/localization/texts.py index b0d05652..fee85c6d 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -5,7 +5,6 @@ import logging from typing import Any, Dict from app.config import settings -from app.utils.pricing_utils import format_period_option_label from app.localization.loader import ( DEFAULT_LANGUAGE, clear_locale_cache, @@ -31,12 +30,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: 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), "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 +49,6 @@ 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), "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/admin_notification_service.py b/app/services/admin_notification_service.py index 3f757d6e..553e794f 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -221,7 +221,7 @@ class AdminNotificationService: ⏰ Параметры триала: 📅 Период: {settings.TRIAL_DURATION_DAYS} дней -📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ +📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)} 📱 Устройства: {trial_device_limit} 🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'} diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index c3a3a527..e4d3d5e5 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -211,7 +211,7 @@ class CryptoBotPaymentMixin: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = ( diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 48b3dba3..c032f990 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -335,7 +335,7 @@ class HeleketPaymentMixin: topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" referrer_info = format_referrer_info(user) subscription = getattr(user, "subscription", None) - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 4ef4f1bf..69f672a9 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -281,13 +281,8 @@ class MulenPayPaymentMixin: ) return False - # Используем предзагруженные значения для избежания lazy-загрузки - promo_group = ( - user.promo_group if hasattr(user, "promo_group") and user.promo_group else None - ) - subscription = ( - user.subscription if hasattr(user, "subscription") and user.subscription else None - ) + promo_group = user.get_primary_promo_group() + subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = ( "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 927bc443..501dbe2c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -362,7 +362,7 @@ class Pal24PaymentMixin: user.balance_kopeks += payment.amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index da2f68ed..9d7e4e33 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -430,7 +430,7 @@ class TelegramStarsMixin: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index c0b7e339..5bff005b 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -464,7 +464,7 @@ class WataPaymentMixin: await db.commit() await db.refresh(user) - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" 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..aaaa37e2 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -50,7 +50,7 @@ def _resolve_addon_discount_percent( *, period_days: Optional[int] = None, ) -> int: - group = promo_group or (getattr(user, "promo_group", None) if user else None) + group = promo_group or (user.get_primary_promo_group() if user else None) if group is not None and not getattr(group, "apply_discounts_to_addons", True): return 0 @@ -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/services/tribute_service.py b/app/services/tribute_service.py index 7e9108bb..c21906d7 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -131,7 +131,7 @@ class TributeService: user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() subscription = getattr(user, "subscription", None) referrer_info = format_referrer_info(user) topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" 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/price_display.py b/app/utils/price_display.py new file mode 100644 index 00000000..75ceeacd --- /dev/null +++ b/app/utils/price_display.py @@ -0,0 +1,187 @@ +""" +Unified price display system for all subscription and balance pricing. + +This module provides a centralized way to: +- Calculate prices with all applicable discounts (promo groups, promo offers) +- Format price buttons consistently across all flows +- Ensure uniform discount display throughout the application +""" + +from dataclasses import dataclass +from typing import Optional +import logging + +from app.database.models import User +from app.config import settings + +logger = logging.getLogger(__name__) + + +@dataclass +class PriceInfo: + """Container for pricing information with discounts.""" + + base_price: int # Original price without any discounts (kopeks) + final_price: int # Final price after all discounts (kopeks) + discount_percent: int # Total discount percentage + + @property + def has_discount(self) -> bool: + """Check if there's any discount applied.""" + return self.base_price > self.final_price and self.discount_percent > 0 + + @property + def discount_value(self) -> int: + """Get the absolute discount value in kopeks.""" + return self.base_price - self.final_price + + +def calculate_user_price( + user: Optional[User], + base_price: int, + period_days: int, + category: str = "period" +) -> PriceInfo: + """ + Calculate final price for a user with all applicable discounts. + + Args: + user: User object (None for base/default pricing from settings) + base_price: Base price without discounts (kopeks) + period_days: Subscription period in days + category: Discount category ("period", "servers", "devices", "traffic") + + Returns: + PriceInfo with base_price, final_price, and discount_percent + + Example: + >>> user = get_user_from_db(123) + >>> price_info = calculate_user_price(user, 100000, 30, "period") + >>> print(f"{price_info.base_price} -> {price_info.final_price} ({price_info.discount_percent}%)") + 100000 -> 80000 (20%) + + >>> # For base pricing (no user) + >>> price_info = calculate_user_price(None, 100000, 30, "period") + >>> # Uses BASE_PROMO_GROUP_PERIOD_DISCOUNTS from settings + """ + if not base_price or base_price <= 0: + return PriceInfo(base_price=base_price or 0, final_price=base_price or 0, discount_percent=0) + + # Get discount percentage + if user: + # Get user's promo group discount for this category + discount_percent = user.get_promo_discount(category, period_days) + else: + # For None user, use base settings discount (only for period category) + if category == "period": + discount_percent = settings.get_base_promo_group_period_discount(period_days) + else: + discount_percent = 0 + + logger.debug( + f"calculate_user_price: user={user.telegram_id if user else 'None'}, " + f"base_price={base_price}, period_days={period_days}, category={category}, " + f"discount_percent={discount_percent}" + ) + + if discount_percent <= 0: + return PriceInfo(base_price=base_price, final_price=base_price, discount_percent=0) + + # Calculate discounted price + discount_value = (base_price * discount_percent) // 100 + final_price = base_price - discount_value + + logger.debug( + f"Calculated price for user {user.telegram_id if user else 'None'}: " + f"{base_price} -> {final_price} (-{discount_percent}%) " + f"[category={category}, period={period_days}]" + ) + + return PriceInfo( + base_price=base_price, + final_price=final_price, + discount_percent=discount_percent + ) + + +def format_price_button( + period_label: str, + price_info: PriceInfo, + format_price_func, + emphasize: bool = False, + add_exclamation: bool = True +) -> str: + """ + Format a price button text with unified discount display. + + Args: + period_label: Label for the period (e.g., "30 дней", "1 месяц") + price_info: PriceInfo object with pricing details + format_price_func: Function to format price (usually texts.format_price) + emphasize: Add fire emojis for emphasis (for best deals) + add_exclamation: Add exclamation mark after discount percent + + Returns: + Formatted button text + + Examples: + With discount: + "📅 30 дней - 990₽ ➜ 693₽ (-30%)!" + + With emphasis: + "🔥 📅 360 дней - 8990₽ ➜ 6293₽ (-30%)! 🔥" + + Without discount: + "📅 30 дней - 990₽" + """ + # Build button text with or without discount + if price_info.has_discount: + exclamation = "!" if add_exclamation else "" + button_text = ( + f"📅 {period_label} - " + f"{format_price_func(price_info.base_price)} ➜ " + f"{format_price_func(price_info.final_price)} " + f"(-{price_info.discount_percent}%){exclamation}" + ) + else: + button_text = f"📅 {period_label} - {format_price_func(price_info.final_price)}" + + # Add emphasis for best deals + if emphasize: + button_text = f"🔥 {button_text} 🔥" + + logger.debug(f"Formatted button: {button_text}") + return button_text + + +def format_price_text( + period_label: str, + price_info: PriceInfo, + format_price_func +) -> str: + """ + Format a price for message text (not button) with unified discount display. + + Args: + period_label: Label for the period (e.g., "30 дней") + price_info: PriceInfo object with pricing details + format_price_func: Function to format price (usually texts.format_price) + + Returns: + Formatted price text for messages + + Examples: + With discount: + "📅 30 дней - 990₽ ➜ 693₽" + + Without discount: + "📅 30 дней - 990₽" + """ + if price_info.has_discount: + return ( + f"📅 {period_label} - " + f"{format_price_func(price_info.base_price)} ➜ " + f"{format_price_func(price_info.final_price)}" + ) + else: + return f"📅 {period_label} - {format_price_func(price_info.final_price)}" diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 88c9602e..a27d62fb 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,21 +313,6 @@ 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: - """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. - """ - - if price and price > 0: - return f"{label} - {settings.format_price(price)}" - - return label - - def validate_pricing_calculation( base_price: int, monthly_additions: int, 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_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py index ba4c757c..0eddaba8 100644 --- a/tests/services/test_payment_service_mulenpay.py +++ b/tests/services/test_payment_service_mulenpay.py @@ -203,6 +203,10 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( self.language = "ru" self.promo_group = None self.subscription = None + self.user_promo_groups = [] + + def get_primary_promo_group(self): + return self.promo_group dummy_user = DummyUser() diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 0381f2cf..61d66f5e 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -208,6 +208,7 @@ async def test_process_mulenpay_callback_success( referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -302,6 +303,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user_crypto(db, user_id): return user @@ -431,6 +433,7 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user if user_id == user.id else None @@ -538,6 +541,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -666,6 +670,7 @@ async def test_process_yookassa_webhook_restores_missing_payment( referred_by_id=None, referrer=None, ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -816,6 +821,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) - referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user @@ -982,6 +988,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey referrer=None, language="ru", ) + user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) async def fake_get_user(db, user_id): return user 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..f12ab46f 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() @@ -82,7 +81,7 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch): db.rollback = rollback_mock - monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock) + monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) monkeypatch.setattr( "app.services.remnawave_service.get_user_by_telegram_id", get_user_mock, @@ -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() @@ -107,7 +105,7 @@ async def test_get_or_create_user_creates_new(monkeypatch): create_user_mock = AsyncMock(return_value=new_user) - monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock) + monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) user, created = await service._get_or_create_bot_user_from_panel(db, panel_user) @@ -117,6 +115,7 @@ async def test_get_or_create_user_creates_new(monkeypatch): db=db, telegram_id=777, username="new_user", - first_name="Panel User 777", + first_name="User 777", + last_name=None, language="ru", ) diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index a4d464d6..123b0cdc 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) @@ -35,6 +34,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): user.balance_kopeks = 200_000 user.language = "ru" user.subscription = None + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "period_days": 30, @@ -185,7 +185,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) @@ -204,6 +203,7 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): user.balance_kopeks = 200_000 user.language = "ru" user.subscription = subscription + user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", @@ -298,3 +298,406 @@ 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() + + +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 + user.get_primary_promo_group = MagicMock(return_value=None) + + 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), + ) + + 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 "", + ) + + 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) + 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() + + +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 + user.get_primary_promo_group = MagicMock(return_value=None) + + 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 был вызван + + +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 + user.get_primary_promo_group = MagicMock(return_value=None) + + 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 "", + ) + + 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.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 БЫЛ ВЫЗВАН! + + +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 + user.get_primary_promo_group = MagicMock(return_value=None) + + 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..7eaf2cd3 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -31,6 +31,11 @@ def mock_user(): user.balance_kopeks = 10000 user.subscription = None user.has_had_paid_subscription = False + user.promo_group_id = None + user.get_primary_promo_group = MagicMock(return_value=None) + user.get_promo_discount = MagicMock(return_value=0) + user.promo_offer_discount_percent = 0 + user.promo_offer_discount_expires_at = None return user @pytest.fixture @@ -54,7 +59,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 +106,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): """Тест возврата к сохраненной корзине с достаточным балансом""" # Подготовим данные корзины @@ -121,10 +124,13 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ patch('app.localization.texts.get_texts') as mock_get_texts, \ - patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func: + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ + patch('app.handlers.subscription.purchase._prepare_subscription_summary') as mock_prepare_summary: # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_cart_service.save_user_cart = AsyncMock(return_value=True) + mock_prepare_summary.return_value = ("summary", {}) mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] mock_format_period.return_value = "30 дней" mock_keyboard = InlineKeyboardMarkup( @@ -143,8 +149,8 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc # Вызываем функцию await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) - # Проверяем, что данные были загружены из корзины и установлены в FSM - mock_state.set_data.assert_called_once_with(cart_data) + # Проверяем, что корзина была загружена + mock_cart_service.get_user_cart.assert_called_once_with(mock_user.id) # Проверяем, что сообщение было отредактировано mock_callback_query.message.edit_text.assert_called_once() @@ -153,7 +159,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 +229,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 +303,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): """Тест возврата к сохраненной корзине с недостаточным балансом""" # Подготовим данные корзины @@ -320,6 +323,7 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) + mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text="Пополнить", callback_data="topup")]] ) @@ -347,7 +351,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 +372,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..dde5d021 --- /dev/null +++ b/tests/utils/test_pricing_utils.py @@ -0,0 +1,52 @@ +""" +Тесты для утилит ценообразования и форматирования цен. + +Этот модуль тестирует функции из app/utils/pricing_utils.py и app/localization/texts.py, +особенно функции отображения цен со скидками на кнопках подписки. +""" + +import pytest +from unittest.mock import patch, MagicMock +from typing import Dict, Any + +from app.localization.texts import _build_dynamic_values + + +# DEPRECATED: format_period_option_label tests removed - function replaced with unified price_display system + + +class TestBuildDynamicValues: + """ + Тесты для функции _build_dynamic_values из texts.py. + + NOTE: PERIOD_*_DAYS константы были удалены из _build_dynamic_values, + так как теперь кнопки периодов генерируются динамически в get_subscription_period_keyboard() + с учетом персональных скидок пользователя. + """ + + @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_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None: + """Должны генерироваться ключи трафика и другие динамические значения.""" + # Настройка моков для traffic цен + 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