1) Отображение скидки на кнопках (красивое!)

2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
This commit is contained in:
Pavel Stryuk
2025-11-04 13:05:02 +01:00
parent 16f4285948
commit 427011fe41
39 changed files with 3263 additions and 213 deletions

12
Makefile Normal file
View File

@@ -0,0 +1,12 @@
.PHONY: up down reload test
up:
docker compose up -d
down:
docker compose down
reload: down up
test:
pytest

View File

@@ -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:

View File

@@ -13,7 +13,10 @@ logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
@@ -27,7 +30,8 @@ async def create_promocode(
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
created_by: Optional[int] = None,
promo_group_id: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
@@ -37,14 +41,18 @@ async def create_promocode(
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
created_by=created_by,
promo_group_id=promo_group_id
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
if promo_group_id:
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
else:
logger.info(f"✅ Создан промокод: {code}")
return promocode
@@ -143,14 +151,17 @@ async def get_promocodes_list(
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
query = select(PromoCode).options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()

View File

@@ -11,6 +11,7 @@ from app.database.models import (
User,
SubscriptionServer,
PromoGroup,
UserPromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
@@ -195,10 +196,27 @@ async def extend_subscription(
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
@@ -207,27 +225,15 @@ async def extend_subscription(
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
@@ -915,7 +921,7 @@ async def get_subscription_renewal_cost(
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)

View File

@@ -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_(

View 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

View File

@@ -67,9 +67,10 @@ class TransactionType(Enum):
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
PROMO_GROUP = "promo_group"
class PaymentMethod(Enum):
@@ -418,6 +419,7 @@ class PromoGroup(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
priority = Column(Integer, nullable=False, default=0, index=True)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
@@ -429,6 +431,7 @@ class PromoGroup(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
@@ -492,6 +495,22 @@ class PromoGroup(Base):
return max(0, min(100, percent))
class UserPromoGroup(Base):
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
__tablename__ = "user_promo_groups"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
assigned_at = Column(DateTime, default=func.now())
assigned_by = Column(String(50), default="system")
user = relationship("User", back_populates="user_promo_groups")
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
def __repr__(self):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class User(Base):
__tablename__ = "users"
@@ -529,23 +548,43 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return None
# Сортируем по приоритету группы (убывание), затем по ID группы
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (upg.promo_group.priority if upg.promo_group else 0, upg.promo_group_id),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
# Fallback на старую связь если новая пустая
return self.promo_group
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
primary_group = self.get_primary_promo_group()
if not primary_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
return primary_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -793,13 +832,15 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:

View File

@@ -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:

View File

@@ -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,

View File

@@ -17,6 +17,7 @@ from app.database.crud.promocode import (
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime
@@ -81,16 +82,24 @@ async def show_promocodes_list(
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 Дней: {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -136,8 +145,13 @@ async def show_promocode_management(
return
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text = f"""
🎫 <b>Управление промокодом</b>
@@ -145,12 +159,17 @@ async def show_promocode_management(
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
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"
@@ -445,13 +464,14 @@ async def select_promocode_type(
state: FSMContext
):
promo_type = callback.data.split('_')[-1]
type_names = {
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка"
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
"group": "🏷️ Промогруппа"
}
await state.update_data(promocode_type=promo_type)
await callback.message.edit_text(
@@ -509,6 +529,77 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
if not groups_with_counts:
await message.answer(
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
)
await state.clear()
return
keyboard = []
text = f"🏷️ <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,17 +799,20 @@ async def process_promocode_expiry(
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
max_uses = data.get('promocode_max_uses', 1)
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
"balance": PromoCodeType.BALANCE,
"days": PromoCodeType.SUBSCRIPTION_DAYS,
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
"group": PromoCodeType.PROMO_GROUP
}
promocode = await create_promocode(
db=db,
code=code,
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
subscription_days=value if promo_type in ["days", "trial"] else 0,
max_uses=max_uses,
valid_until=valid_until,
created_by=db_user.id
created_by=db_user.id,
promo_group_id=promo_group_id if promo_type == "group" else None
)
type_names = {
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка"
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка",
"group": "Промогруппа"
}
summary_text = f"""
✅ <b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
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"
if promocode.valid_until:
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))

View File

@@ -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:

View File

@@ -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)
return
# Check if user already has this group
has_group = await has_user_promo_group(db, user_id, group_id)
user_service = UserService()
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
db,
user_id,
group_id
)
if has_group:
# Remove group
# Check if it's the last group
groups_count = await count_user_promo_groups(db, user_id)
if groups_count <= 1:
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST",
"❌ Нельзя удалить последнюю промогруппу",
),
show_alert=True
)
return
if not success or not updated_user or not new_group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
group = await get_promo_group_by_id(db, group_id)
await remove_user_from_promo_group(db, user_id, group_id)
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_REMOVED",
"🗑 Группа «{name}» удалена",
).format(name=group.name if group else ""),
show_alert=True
)
else:
# Add group
group = await get_promo_group_by_id(db, group_id)
if not group:
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
return
await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin")
await callback.answer(
texts.t(
"ADMIN_USER_PROMO_GROUP_ADDED",
"✅ Группа «{name}» добавлена",
).format(name=group.name),
show_alert=True
)
# Refresh user data and show updated list
user = await get_user_by_id(db, user_id)
promo_groups = await get_promo_groups_with_counts(db)
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
await callback.answer(
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
show_alert=True
)
try:
notification_service = AdminNotificationService(callback.bot)
reason = (
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
)
await notification_service.send_user_promo_group_change_notification(
db,
updated_user,
old_group,
new_group,
reason=reason,
initiator=db_user,
automatic=False,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
updated_user.telegram_id,
notify_error,
)
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
@@ -2280,9 +2341,10 @@ async def show_inactive_users(
text = f"🗑️ <b>Неактивные пользователи</b>\n"
text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n"
for user in inactive_users[:10]:
text += f"👤 {user.full_name}\n"
for user in inactive_users[:10]:
user_link = f'<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(

View File

@@ -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"
)
],
[

View File

@@ -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}"
)
])

View File

@@ -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:",

View File

@@ -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": "Доступные категории:",

View File

@@ -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)}",

View File

@@ -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()

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,

View File

@@ -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()

View File

@@ -139,7 +139,7 @@ async def compute_simple_subscription_price(
promo_group = await get_promo_group_by_id(db, int(promo_group_id))
if promo_group is None and user is not None:
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
period_discount_percent = resolve_discount_percent(
user,
@@ -313,19 +313,43 @@ def format_period_description(days: int, language: str = "ru") -> str:
return f"{days} days ({months} {month_word})"
def format_period_option_label(label: str, price: int) -> str:
def format_period_option_label(
label: str,
price: int,
original_price: int = 0,
discount_percent: int = 0
) -> str:
"""Return a period option label with price when it's greater than zero.
When the price is zero or negative, the price suffix is omitted so that the
option does not misleadingly show "0" as the cost of the period. This keeps
the UI consistent when pricing is calculated dynamically based on other
parameters such as servers or devices.
Args:
label: The base label text (e.g., "📅 30 дней")
price: The final price after discount
original_price: The original price before discount (optional)
discount_percent: The discount percentage (optional)
Returns:
Formatted label with price and discount info if applicable
"""
if price and price > 0:
return f"{label} - {settings.format_price(price)}"
if not price or price <= 0:
return label
return label
# If there's a discount, show crossed-out original price and discount percentage
if original_price > 0 and discount_percent > 0 and original_price > price:
return (
f"{label} - "
f"<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(

View File

@@ -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
View File

View 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
View File

@@ -0,0 +1 @@
"""Test fixtures package"""

206
tests/fixtures/promocode_fixtures.py vendored Normal file
View 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

View File

View 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

View 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

View File

@@ -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,

View File

@@ -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()

View File

@@ -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}"

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View 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