mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет 3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом 4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её 5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке 6) Исправить баг исчезновения триалки при пополнении 7) Исправить падающие тесты и добавить новых 8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
This commit is contained in:
12
Makefile
Normal file
12
Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
.PHONY: up down reload test
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
reload: down up
|
||||
|
||||
test:
|
||||
pytest
|
||||
@@ -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:
|
||||
|
||||
@@ -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,13 +41,17 @@ 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)
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
@@ -144,7 +152,10 @@ async def get_promocodes_list(
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -199,6 +200,23 @@ async def extend_subscription(
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.models import (
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
@@ -38,7 +39,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
@@ -56,7 +57,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
@@ -79,7 +80,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
@@ -749,7 +750,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
@@ -817,7 +818,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
|
||||
260
app/database/crud/user_promo_group.py
Normal file
260
app/database/crud/user_promo_group.py
Normal file
@@ -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
|
||||
@@ -70,6 +70,7 @@ class PromoCodeType(Enum):
|
||||
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"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -529,8 +548,9 @@ 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
|
||||
@@ -542,10 +562,29 @@ class User(Base):
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return None
|
||||
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
sorted_groups = sorted(
|
||||
self.user_promo_groups,
|
||||
key=lambda upg: (upg.promo_group.priority if upg.promo_group else 0, upg.promo_group_id),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
|
||||
# Fallback на старую связь если новая пустая
|
||||
return self.promo_group
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
primary_group = self.get_primary_promo_group()
|
||||
if not primary_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
return primary_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
@@ -795,11 +834,13 @@ class PromoCode(Base):
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
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,
|
||||
|
||||
@@ -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,7 +82,12 @@ 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} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
@@ -90,6 +96,9 @@ async def show_promocodes_list(
|
||||
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,7 +145,12 @@ 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"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
@@ -150,6 +164,11 @@ async def show_promocode_management(
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
|
||||
elif promo.promo_group_id:
|
||||
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
@@ -449,7 +468,8 @@ async def select_promocode_type(
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
}
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
@@ -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"🏷️ <b>Промокод:</b> <code>{code}</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"🏷️ <b>Промокод для промогруппы</b>\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,6 +799,8 @@ 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:
|
||||
@@ -716,7 +809,8 @@ async def process_promocode_expiry(
|
||||
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(
|
||||
@@ -727,13 +821,15 @@ 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": "Тестовая подписка"
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
}
|
||||
|
||||
summary_text = f"""
|
||||
@@ -747,6 +843,8 @@ async def process_promocode_expiry(
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
elif promo_type == "group" and promo_group_name:
|
||||
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
@@ -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_"))
|
||||
|
||||
@@ -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'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
|
||||
@@ -1008,7 +1008,8 @@ async def _render_user_subscription_overview(
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = "📱 <b>Подписка и настройки пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
@@ -1168,7 +1169,8 @@ async def show_user_transactions(
|
||||
transactions = await get_user_transactions(db, user_id, limit=10)
|
||||
|
||||
text = f"💳 <b>Транзакции пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\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'<a href="tg://user?id={referral.telegram_id}">{referral.full_name}</a>'
|
||||
items.append(
|
||||
texts.t(
|
||||
"ADMIN_USER_REFERRALS_LIST_ITEM",
|
||||
"• {name} (ID: <code>{telegram_id}</code>{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)
|
||||
# Check if user already has this group
|
||||
has_group = await has_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
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
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
|
||||
)
|
||||
|
||||
if not success or not updated_user or not new_group:
|
||||
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
|
||||
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
|
||||
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
|
||||
await callback.answer(
|
||||
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
|
||||
texts.t(
|
||||
"ADMIN_USER_PROMO_GROUP_ADDED",
|
||||
"✅ Группа «{name}» добавлена",
|
||||
).format(name=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,
|
||||
)
|
||||
# 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, user, promo_groups)
|
||||
|
||||
|
||||
|
||||
@@ -2282,7 +2343,8 @@ async def show_inactive_users(
|
||||
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
text += f"👤 {user.full_name}\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link}\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\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"📊 <b>Статистика пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
text += f"👤 {user_link} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
text += f"<b>Основная информация:</b>\n"
|
||||
text += f"• Дней с регистрации: {profile['registration_days']}\n"
|
||||
@@ -4005,7 +4068,8 @@ async def admin_buy_subscription(
|
||||
])
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
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"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
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'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
|
||||
await callback.message.edit_text(
|
||||
f"{message}\n\n"
|
||||
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
|
||||
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
|
||||
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -4324,7 +4390,8 @@ async def admin_buy_subscription_execute(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
])
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4541,7 +4608,7 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
F.data.startswith("admin_user_promo_group_toggle_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1965,15 +1965,33 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
|
||||
|
||||
if isinstance(price_info, dict):
|
||||
final_price = price_info.get("final")
|
||||
original_price = price_info.get("original", 0)
|
||||
if final_price is None:
|
||||
final_price = price_info.get("original", 0)
|
||||
else:
|
||||
final_price = price_info
|
||||
original_price = price_info
|
||||
|
||||
period_display = format_period_description(days, language)
|
||||
|
||||
# Show discount if there is one
|
||||
if original_price > final_price and original_price > 0:
|
||||
discount_percent = ((original_price - final_price) * 100) // original_price
|
||||
button_text = (
|
||||
f"📅 {period_display} - "
|
||||
f"<s>{texts.format_price(original_price)}</s> "
|
||||
f"{texts.format_price(final_price)} "
|
||||
f"(-{discount_percent}%)"
|
||||
)
|
||||
# Add fire emojis for 360 days
|
||||
if days == 360:
|
||||
button_text = f"🔥 {button_text} 🔥"
|
||||
else:
|
||||
button_text = f"📅 {period_display} - {texts.format_price(final_price)}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display} - {texts.format_price(final_price)}",
|
||||
text=button_text,
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -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": "🎯 <b>Promo offers</b>\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:",
|
||||
|
||||
@@ -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": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
|
||||
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
|
||||
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
|
||||
|
||||
@@ -29,14 +29,36 @@ def _get_cached_rules_value(language: str) -> str:
|
||||
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
|
||||
# Helper function to format period with discount
|
||||
def format_period_with_discount(label: str, period_days: int, base_price: int) -> str:
|
||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||
if discount_percent > 0:
|
||||
# Calculate discounted price
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
discounted_price, _ = apply_percentage_discount(base_price, discount_percent)
|
||||
result = format_period_option_label(
|
||||
label,
|
||||
discounted_price,
|
||||
base_price,
|
||||
discount_percent
|
||||
)
|
||||
else:
|
||||
result = format_period_option_label(label, base_price)
|
||||
|
||||
# Add fire emojis for 360 days period
|
||||
if period_days == 360 and discount_percent > 0:
|
||||
result = f"🔥 {result} 🔥"
|
||||
|
||||
return result
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"PERIOD_14_DAYS": format_period_option_label("📅 14 дней", settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_option_label("📅 30 дней", settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_option_label("📅 60 дней", settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_option_label("📅 90 дней", settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_option_label("📅 180 дней", settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_option_label("📅 360 дней", settings.PRICE_360_DAYS),
|
||||
"PERIOD_14_DAYS": format_period_with_discount("📅 14 дней", 14, settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_with_discount("📅 30 дней", 30, settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_with_discount("📅 60 дней", 60, settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_with_discount("📅 90 дней", 90, settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_with_discount("📅 180 дней", 180, settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_with_discount("📅 360 дней", 360, settings.PRICE_360_DAYS),
|
||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
@@ -56,12 +78,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
|
||||
if language_code == "en":
|
||||
return {
|
||||
"PERIOD_14_DAYS": format_period_option_label("📅 14 days", settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_option_label("📅 30 days", settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_option_label("📅 60 days", settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_option_label("📅 90 days", settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_option_label("📅 180 days", settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_option_label("📅 360 days", settings.PRICE_360_DAYS),
|
||||
"PERIOD_14_DAYS": format_period_with_discount("📅 14 days", 14, settings.PRICE_14_DAYS),
|
||||
"PERIOD_30_DAYS": format_period_with_discount("📅 30 days", 30, settings.PRICE_30_DAYS),
|
||||
"PERIOD_60_DAYS": format_period_with_discount("📅 60 days", 60, settings.PRICE_60_DAYS),
|
||||
"PERIOD_90_DAYS": format_period_with_discount("📅 90 days", 90, settings.PRICE_90_DAYS),
|
||||
"PERIOD_180_DAYS": format_period_with_discount("📅 180 days", 180, settings.PRICE_180_DAYS),
|
||||
"PERIOD_360_DAYS": format_period_with_discount("📅 360 days", 360, settings.PRICE_360_DAYS),
|
||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,20 +313,44 @@ def format_period_description(days: int, language: str = "ru") -> str:
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
def format_period_option_label(label: str, price: int) -> str:
|
||||
def format_period_option_label(
|
||||
label: str,
|
||||
price: int,
|
||||
original_price: int = 0,
|
||||
discount_percent: int = 0
|
||||
) -> str:
|
||||
"""Return a period option label with price when it's greater than zero.
|
||||
|
||||
When the price is zero or negative, the price suffix is omitted so that the
|
||||
option does not misleadingly show "0" as the cost of the period. This keeps
|
||||
the UI consistent when pricing is calculated dynamically based on other
|
||||
parameters such as servers or devices.
|
||||
|
||||
Args:
|
||||
label: The base label text (e.g., "📅 30 дней")
|
||||
price: The final price after discount
|
||||
original_price: The original price before discount (optional)
|
||||
discount_percent: The discount percentage (optional)
|
||||
|
||||
Returns:
|
||||
Formatted label with price and discount info if applicable
|
||||
"""
|
||||
|
||||
if price and price > 0:
|
||||
return f"{label} - {settings.format_price(price)}"
|
||||
|
||||
if not price or price <= 0:
|
||||
return label
|
||||
|
||||
# If there's a discount, show crossed-out original price and discount percentage
|
||||
if original_price > 0 and discount_percent > 0 and original_price > price:
|
||||
return (
|
||||
f"{label} - "
|
||||
f"<s>{settings.format_price(original_price)}</s> "
|
||||
f"{settings.format_price(price)} "
|
||||
f"(-{discount_percent}%)"
|
||||
)
|
||||
|
||||
# No discount, show price only
|
||||
return f"{label} - {settings.format_price(price)}"
|
||||
|
||||
|
||||
def validate_pricing_calculation(
|
||||
base_price: int,
|
||||
|
||||
@@ -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")
|
||||
|
||||
0
tests/crud/__init__.py
Normal file
0
tests/crud/__init__.py
Normal file
142
tests/crud/test_promocode_crud.py
Normal file
142
tests/crud/test_promocode_crud.py
Normal file
@@ -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()
|
||||
1
tests/fixtures/__init__.py
vendored
Normal file
1
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"""Test fixtures package"""
|
||||
206
tests/fixtures/promocode_fixtures.py
vendored
Normal file
206
tests/fixtures/promocode_fixtures.py
vendored
Normal file
@@ -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
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
338
tests/integration/test_promocode_promo_group_flow.py
Normal file
338
tests/integration/test_promocode_promo_group_flow.py
Normal file
@@ -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
|
||||
584
tests/services/test_promocode_service.py
Normal file
584
tests/services/test_promocode_service.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -66,7 +66,6 @@ def test_deduplicate_ignores_records_without_expire_date():
|
||||
assert deduplicated[telegram_id] is valid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_user_handles_unique_violation(monkeypatch):
|
||||
service = _create_service()
|
||||
db = AsyncMock()
|
||||
@@ -97,7 +96,6 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch):
|
||||
rollback_mock.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_user_creates_new(monkeypatch):
|
||||
service = _create_service()
|
||||
db = AsyncMock()
|
||||
|
||||
@@ -25,7 +25,6 @@ class DummyTexts:
|
||||
return f"{value / 100:.0f} ₽"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
@@ -185,7 +184,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
@@ -298,3 +296,379 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
bot.send_message.assert_awaited()
|
||||
service_mock.update_remnawave_user.assert_awaited()
|
||||
create_transaction_mock.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch):
|
||||
"""Тест: триал сохраняется, если не хватает денег для автопокупки"""
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 123
|
||||
subscription.is_trial = True # Триальная подписка!
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 10
|
||||
subscription.connected_squads = []
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 99
|
||||
user.telegram_id = 9999
|
||||
# ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243),
|
||||
# но subtract_user_balance вернёт False (симуляция неудачи списания)
|
||||
user.balance_kopeks = 60_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 50_000,
|
||||
"description": "Продление на 30 дней",
|
||||
"device_limit": 1,
|
||||
"traffic_limit_gb": 100,
|
||||
"squad_uuid": None,
|
||||
"consume_promo_offer": False,
|
||||
}
|
||||
|
||||
# Mock: недостаточно денег, списание не удалось
|
||||
subtract_mock = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
bot = AsyncMock()
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
# Проверки
|
||||
assert result is False # Автопокупка не удалась
|
||||
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
|
||||
subtract_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch):
|
||||
"""Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления"""
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 456
|
||||
subscription.is_trial = True # Триальная подписка!
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 10
|
||||
subscription.connected_squads = []
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 88
|
||||
user.telegram_id = 8888
|
||||
user.balance_kopeks = 200_000 # Достаточно денег
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 100_000,
|
||||
"description": "Продление на 30 дней",
|
||||
"device_limit": 2,
|
||||
"traffic_limit_gb": 500,
|
||||
"squad_uuid": None,
|
||||
"consume_promo_offer": False,
|
||||
}
|
||||
|
||||
# Mock: деньги списались успешно
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
# Mock: продление успешно
|
||||
async def extend_stub(db, current_subscription, days):
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_stub,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
# ИСПРАВЛЕНО: Добавлен мок для format_local_datetime
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_local_datetime",
|
||||
lambda dt, fmt: dt.strftime(fmt) if dt else "",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
|
||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
||||
bot = AsyncMock()
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
# Проверки
|
||||
assert result is True # Автопокупка успешна
|
||||
assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН!
|
||||
assert subscription.status == "active"
|
||||
db_session.commit.assert_awaited() # Commit был вызван
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch):
|
||||
"""Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription"""
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 789
|
||||
subscription.is_trial = True # Триальная подписка!
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=3)
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 10
|
||||
subscription.connected_squads = []
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 77
|
||||
user.telegram_id = 7777
|
||||
user.balance_kopeks = 200_000 # Достаточно денег
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 100_000,
|
||||
"description": "Продление на 30 дней",
|
||||
"device_limit": 1,
|
||||
"traffic_limit_gb": 100,
|
||||
"squad_uuid": None,
|
||||
"consume_promo_offer": False,
|
||||
}
|
||||
|
||||
# Mock: деньги списались успешно
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
# Mock: extend_subscription выбрасывает ошибку!
|
||||
async def extend_error(db, current_subscription, days):
|
||||
raise Exception("Database connection error")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_error,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
|
||||
# ИСПРАВЛЕНО: Добавлены недостающие моки
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_local_datetime",
|
||||
lambda dt, fmt: dt.strftime(fmt) if dt else "",
|
||||
)
|
||||
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
|
||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
||||
bot = AsyncMock()
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
# Проверки
|
||||
assert result is False # Автопокупка не удалась
|
||||
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
|
||||
db_session.rollback.assert_awaited() # ROLLBACK БЫЛ ВЫЗВАН!
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
|
||||
"""Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True"""
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено!
|
||||
|
||||
now = datetime.utcnow()
|
||||
trial_end = now + timedelta(days=2) # Осталось 2 дня триала
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 321
|
||||
subscription.is_trial = True
|
||||
subscription.status = "active"
|
||||
subscription.end_date = trial_end
|
||||
subscription.start_date = now - timedelta(days=1) # Триал начался вчера
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 10
|
||||
subscription.connected_squads = []
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 66
|
||||
user.telegram_id = 6666
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30, # Покупает 30 дней
|
||||
"total_price": 100_000,
|
||||
"description": "Продление на 30 дней",
|
||||
"device_limit": 1,
|
||||
"traffic_limit_gb": 100,
|
||||
"squad_uuid": None,
|
||||
"consume_promo_offer": False,
|
||||
}
|
||||
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
# Mock: extend_subscription с логикой переноса бонусных дней
|
||||
# Имитируем нашу новую логику из extend_subscription()
|
||||
async def extend_with_bonus(db, current_subscription, days):
|
||||
# Вычисляем бонусные дни (как в нашем коде)
|
||||
bonus_days = 0
|
||||
if current_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
if current_subscription.end_date and current_subscription.end_date > now:
|
||||
remaining = current_subscription.end_date - now
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
|
||||
total_days = days + bonus_days
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=total_days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_with_bonus,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_local_datetime",
|
||||
lambda dt, fmt: dt.strftime(fmt),
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
db_session.commit = AsyncMock()
|
||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
||||
bot = AsyncMock()
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
# Проверки
|
||||
assert result is True
|
||||
assert subscription.is_trial is False # Триал конвертирован
|
||||
|
||||
# Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных)
|
||||
# end_date должна быть примерно на 32 дня от оригинального trial_end
|
||||
expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2)
|
||||
actual_delta = (subscription.end_date - trial_end).days
|
||||
assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ def mock_state():
|
||||
state.clear = AsyncMock()
|
||||
return state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест сохранения корзины и перенаправления к пополнению"""
|
||||
# Мокаем все зависимости
|
||||
@@ -102,7 +101,6 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state,
|
||||
# mock_callback_query.answer не должен быть вызван
|
||||
mock_callback_query.answer.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест возврата к сохраненной корзине с достаточным балансом"""
|
||||
# Подготовим данные корзины
|
||||
@@ -153,7 +151,6 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_skips_edit_when_message_matches(
|
||||
mock_callback_query,
|
||||
mock_state,
|
||||
@@ -224,7 +221,6 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches(
|
||||
mock_cart_service.save_user_cart.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
|
||||
mock_callback_query,
|
||||
mock_state,
|
||||
@@ -299,7 +295,6 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled(
|
||||
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
|
||||
# Подготовим данные корзины
|
||||
@@ -347,7 +342,6 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock
|
||||
# (ответ отправляется через return до вызова callback.answer())
|
||||
mock_callback_query.answer.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Тест очистки сохраненной корзины"""
|
||||
# Мокаем все зависимости
|
||||
@@ -369,7 +363,6 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock
|
||||
# Проверяем, что вызван answer
|
||||
mock_callback_query.answer.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
|
||||
"""Отмена покупки должна очищать сохраненную корзину"""
|
||||
mock_clear_draft = AsyncMock()
|
||||
|
||||
@@ -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
|
||||
|
||||
405
tests/utils/test_pricing_utils.py
Normal file
405
tests/utils/test_pricing_utils.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Тесты для утилит ценообразования и форматирования цен.
|
||||
|
||||
Этот модуль тестирует функции из app/utils/pricing_utils.py и app/localization/texts.py,
|
||||
особенно функции отображения цен со скидками на кнопках подписки.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.utils.pricing_utils import format_period_option_label
|
||||
from app.localization.texts import _build_dynamic_values
|
||||
|
||||
|
||||
class TestFormatPeriodOptionLabel:
|
||||
"""Тесты для функции format_period_option_label."""
|
||||
|
||||
def test_format_with_price_only_no_discount(self) -> None:
|
||||
"""Цена без скидки должна отображаться в простом формате."""
|
||||
result = format_period_option_label("📅 30 дней", 99000)
|
||||
assert result == "📅 30 дней - 990 ₽"
|
||||
|
||||
def test_format_with_discount_shows_strikethrough(self) -> None:
|
||||
"""Цена со скидкой должна показывать зачёркнутую оригинальную цену."""
|
||||
result = format_period_option_label(
|
||||
"📅 30 дней",
|
||||
price=69300,
|
||||
original_price=99000,
|
||||
discount_percent=30
|
||||
)
|
||||
assert result == "📅 30 дней - <s>990 ₽</s> 693 ₽ (-30%)"
|
||||
|
||||
def test_format_with_zero_price_returns_label_only(self) -> None:
|
||||
"""Нулевая цена должна возвращать только метку без цены."""
|
||||
result = format_period_option_label("📅 30 дней", 0)
|
||||
assert result == "📅 30 дней"
|
||||
|
||||
def test_format_with_negative_price_returns_label_only(self) -> None:
|
||||
"""Отрицательная цена должна возвращать только метку."""
|
||||
result = format_period_option_label("📅 30 дней", -1000)
|
||||
assert result == "📅 30 дней"
|
||||
|
||||
def test_format_with_zero_discount_percent_shows_simple_price(self) -> None:
|
||||
"""Нулевая скидка должна отображать простую цену без зачёркивания."""
|
||||
result = format_period_option_label(
|
||||
"📅 30 дней",
|
||||
price=99000,
|
||||
original_price=99000,
|
||||
discount_percent=0
|
||||
)
|
||||
assert result == "📅 30 дней - 990 ₽"
|
||||
|
||||
def test_format_with_original_price_equal_to_final_shows_simple(self) -> None:
|
||||
"""Если оригинальная цена равна финальной, показывать простой формат."""
|
||||
result = format_period_option_label(
|
||||
"📅 30 дней",
|
||||
price=99000,
|
||||
original_price=99000,
|
||||
discount_percent=10 # Указана скидка, но цены равны
|
||||
)
|
||||
assert result == "📅 30 дней - 990 ₽"
|
||||
|
||||
def test_format_with_original_price_less_than_final_shows_simple(self) -> None:
|
||||
"""Если оригинальная цена меньше финальной (некорректно), показывать простой формат."""
|
||||
result = format_period_option_label(
|
||||
"📅 30 дней",
|
||||
price=99000,
|
||||
original_price=50000,
|
||||
discount_percent=10
|
||||
)
|
||||
assert result == "📅 30 дней - 990 ₽"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label,price,original,discount,expected",
|
||||
[
|
||||
# Базовые случаи
|
||||
("📅 14 дней", 50000, 0, 0, "📅 14 дней - 500 ₽"),
|
||||
("📅 30 дней", 99000, 0, 0, "📅 30 дней - 990 ₽"),
|
||||
("📅 360 дней", 899000, 0, 0, "📅 360 дней - 8990 ₽"),
|
||||
|
||||
# Со скидками
|
||||
("📅 30 дней", 69300, 99000, 30, "📅 30 дней - <s>990 ₽</s> 693 ₽ (-30%)"),
|
||||
("📅 90 дней", 188300, 269000, 30, "📅 90 дней - <s>2690 ₽</s> 1883 ₽ (-30%)"),
|
||||
("📅 360 дней", 629300, 899000, 30, "📅 360 дней - <s>8990 ₽</s> 6293 ₽ (-30%)"),
|
||||
|
||||
# Разные проценты скидок
|
||||
("📅 30 дней", 89100, 99000, 10, "📅 30 дней - <s>990 ₽</s> 891 ₽ (-10%)"),
|
||||
("📅 30 дней", 49500, 99000, 50, "📅 30 дней - <s>990 ₽</s> 495 ₽ (-50%)"),
|
||||
|
||||
# Цены с копейками
|
||||
("📅 7 дней", 12345, 0, 0, "📅 7 дней - 123.45 ₽"),
|
||||
("📅 7 дней", 12350, 0, 0, "📅 7 дней - 123.5 ₽"),
|
||||
],
|
||||
)
|
||||
def test_format_various_scenarios(
|
||||
self,
|
||||
label: str,
|
||||
price: int,
|
||||
original: int,
|
||||
discount: int,
|
||||
expected: str
|
||||
) -> None:
|
||||
"""Различные сценарии форматирования должны работать корректно."""
|
||||
result = format_period_option_label(label, price, original, discount)
|
||||
assert result == expected
|
||||
|
||||
def test_format_with_100_percent_discount(self) -> None:
|
||||
"""100% скидка должна корректно отображаться."""
|
||||
result = format_period_option_label(
|
||||
"📅 30 дней",
|
||||
price=0,
|
||||
original_price=99000,
|
||||
discount_percent=100
|
||||
)
|
||||
# Цена 0, поэтому возвращается только label
|
||||
assert result == "📅 30 дней"
|
||||
|
||||
def test_format_preserves_label_emojis(self) -> None:
|
||||
"""Эмодзи в метке должны сохраняться."""
|
||||
result = format_period_option_label("🔥 📅 360 дней 🔥", 899000)
|
||||
assert result == "🔥 📅 360 дней 🔥 - 8990 ₽"
|
||||
|
||||
def test_format_with_large_prices(self) -> None:
|
||||
"""Большие цены должны корректно форматироваться."""
|
||||
result = format_period_option_label(
|
||||
"📅 720 дней",
|
||||
price=150000000, # 1,500,000 рублей
|
||||
original_price=200000000,
|
||||
discount_percent=25
|
||||
)
|
||||
assert result == "📅 720 дней - <s>2000000 ₽</s> 1500000 ₽ (-25%)"
|
||||
|
||||
def test_format_with_small_prices_kopeks(self) -> None:
|
||||
"""Маленькие цены с копейками должны корректно отображаться."""
|
||||
result = format_period_option_label(
|
||||
"📅 1 день",
|
||||
price=5050, # 50.50 рублей
|
||||
original_price=10000,
|
||||
discount_percent=50
|
||||
)
|
||||
assert result == "📅 1 день - <s>100 ₽</s> 50.5 ₽ (-50%)"
|
||||
|
||||
def test_format_without_optional_params_uses_defaults(self) -> None:
|
||||
"""Вызов без опциональных параметров должен использовать значения по умолчанию."""
|
||||
result = format_period_option_label("📅 30 дней", 99000)
|
||||
assert result == "📅 30 дней - 990 ₽"
|
||||
|
||||
|
||||
class TestBuildDynamicValues:
|
||||
"""Тесты для функции _build_dynamic_values из texts.py."""
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_russian_language_generates_period_keys(self, mock_settings: MagicMock) -> None:
|
||||
"""Русский язык должен генерировать все ключи периодов."""
|
||||
# Настройка моков
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 0
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Мок для traffic цен
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
assert "PERIOD_14_DAYS" in result
|
||||
assert "PERIOD_30_DAYS" in result
|
||||
assert "PERIOD_60_DAYS" in result
|
||||
assert "PERIOD_90_DAYS" in result
|
||||
assert "PERIOD_180_DAYS" in result
|
||||
assert "PERIOD_360_DAYS" in result
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_english_language_generates_period_keys(self, mock_settings: MagicMock) -> None:
|
||||
"""Английский язык должен генерировать все ключи периодов."""
|
||||
# Настройка моков
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 0
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Мок для traffic цен
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("en-US")
|
||||
|
||||
assert "PERIOD_14_DAYS" in result
|
||||
assert "PERIOD_30_DAYS" in result
|
||||
assert "PERIOD_360_DAYS" in result
|
||||
# Проверяем, что используется "days" а не "дней"
|
||||
assert "days" in result["PERIOD_30_DAYS"]
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
@patch('app.utils.pricing_utils.apply_percentage_discount')
|
||||
def test_period_with_discount_shows_strikethrough(
|
||||
self,
|
||||
mock_apply_discount: MagicMock,
|
||||
mock_settings: MagicMock
|
||||
) -> None:
|
||||
"""Период со скидкой должен показывать зачёркнутую цену."""
|
||||
# Настройка моков
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 30
|
||||
mock_apply_discount.return_value = (69300, 29700) # 30% скидка
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Остальные цены
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
# Проверяем, что есть зачёркивание и процент скидки
|
||||
assert "<s>990 ₽</s>" in result["PERIOD_30_DAYS"]
|
||||
assert "(-30%)" in result["PERIOD_30_DAYS"]
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_period_360_with_discount_has_fire_emojis(self, mock_settings: MagicMock) -> None:
|
||||
"""Период 360 дней со скидкой должен иметь огоньки 🔥."""
|
||||
# Настройка моков для 360 дней со скидкой
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
|
||||
def get_discount(period_days: int) -> int:
|
||||
return 30 if period_days == 360 else 0
|
||||
|
||||
mock_settings.get_base_promo_group_period_discount.side_effect = get_discount
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Остальные цены
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
# Проверяем наличие огоньков
|
||||
assert result["PERIOD_360_DAYS"].startswith("🔥")
|
||||
assert result["PERIOD_360_DAYS"].endswith("🔥")
|
||||
assert result["PERIOD_360_DAYS"].count("🔥") == 2
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_period_360_without_discount_no_fire_emojis(self, mock_settings: MagicMock) -> None:
|
||||
"""Период 360 дней без скидки НЕ должен иметь огоньки 🔥."""
|
||||
# Настройка моков для 360 дней БЕЗ скидки
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 0 # Нет скидки
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Остальные цены
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
# Проверяем отсутствие огоньков
|
||||
assert "🔥" not in result["PERIOD_360_DAYS"]
|
||||
# Но должна быть просто цена
|
||||
assert "8990 ₽" in result["PERIOD_360_DAYS"]
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_other_periods_never_have_fire_emojis(self, mock_settings: MagicMock) -> None:
|
||||
"""Другие периоды (не 360) никогда не должны иметь огоньки, даже со скидкой."""
|
||||
# Настройка моков - 30 дней со скидкой
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
|
||||
def get_discount(period_days: int) -> int:
|
||||
return 30 if period_days == 30 else 0
|
||||
|
||||
mock_settings.get_base_promo_group_period_discount.side_effect = get_discount
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
|
||||
# Остальные цены
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
# 30 дней со скидкой не должно иметь огоньков
|
||||
assert "🔥" not in result["PERIOD_30_DAYS"]
|
||||
# Но должна быть скидка
|
||||
assert "<s>" in result["PERIOD_30_DAYS"]
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_returns_empty_dict_for_unknown_language(self, mock_settings: MagicMock) -> None:
|
||||
"""Неизвестный язык должен возвращать пустой словарь."""
|
||||
result = _build_dynamic_values("fr-FR") # Французский не поддерживается
|
||||
assert result == {}
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_language_code_extraction_works(self, mock_settings: MagicMock) -> None:
|
||||
"""Должна корректно извлекаться языковая часть из locale."""
|
||||
# Настройка моков
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 0
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
# Тест с полным locale кодом
|
||||
result1 = _build_dynamic_values("ru-RU")
|
||||
result2 = _build_dynamic_values("ru")
|
||||
result3 = _build_dynamic_values("RU-ru")
|
||||
|
||||
# Все должны вернуть русские значения
|
||||
assert "дней" in result1["PERIOD_30_DAYS"]
|
||||
assert "дней" in result2["PERIOD_30_DAYS"]
|
||||
assert "дней" in result3["PERIOD_30_DAYS"]
|
||||
|
||||
@patch('app.localization.texts.settings')
|
||||
def test_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None:
|
||||
"""Должны генерироваться не только периоды, но и ключи трафика."""
|
||||
# Настройка моков
|
||||
mock_settings.PRICE_14_DAYS = 50000
|
||||
mock_settings.PRICE_30_DAYS = 99000
|
||||
mock_settings.PRICE_60_DAYS = 189000
|
||||
mock_settings.PRICE_90_DAYS = 269000
|
||||
mock_settings.PRICE_180_DAYS = 499000
|
||||
mock_settings.PRICE_360_DAYS = 899000
|
||||
mock_settings.get_base_promo_group_period_discount.return_value = 0
|
||||
mock_settings.format_price = lambda x: f"{x // 100} ₽"
|
||||
mock_settings.PRICE_TRAFFIC_5GB = 10000
|
||||
mock_settings.PRICE_TRAFFIC_10GB = 20000
|
||||
mock_settings.PRICE_TRAFFIC_25GB = 30000
|
||||
mock_settings.PRICE_TRAFFIC_50GB = 40000
|
||||
mock_settings.PRICE_TRAFFIC_100GB = 50000
|
||||
mock_settings.PRICE_TRAFFIC_250GB = 60000
|
||||
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
|
||||
|
||||
result = _build_dynamic_values("ru-RU")
|
||||
|
||||
# Проверяем наличие ключей трафика
|
||||
assert "TRAFFIC_5GB" in result
|
||||
assert "TRAFFIC_10GB" in result
|
||||
assert "TRAFFIC_UNLIMITED" in result
|
||||
assert "SUPPORT_INFO" in result
|
||||
Reference in New Issue
Block a user