Merge pull request #1708 from reshifter1/main

Пачка правок
This commit is contained in:
Egor
2025-11-05 17:19:32 +03:00
committed by GitHub
64 changed files with 3279 additions and 366 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore

17
Makefile Normal file
View File

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

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)
@@ -66,7 +67,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
)
.where(User.telegram_id == telegram_id)
@@ -99,7 +100,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
)
.where(func.lower(User.username) == normalized)
@@ -833,7 +834,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
)
.where(User.referred_by_id == user_id)
@@ -940,7 +941,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
)
.where(

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,51 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, "promo_group", None)
try:
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
upg.promo_group_id
),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
return getattr(self, "promo_group", None)
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
primary_group = self.get_primary_promo_group()
if not primary_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
return primary_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -793,13 +840,15 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:

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:
@@ -4536,12 +4603,12 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_promo_group,
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_")
)
dp.callback_query.register(
set_user_promo_group,
F.data.startswith("admin_user_promo_group_set_")
F.data.startswith("admin_user_promo_group_toggle_")
)
dp.callback_query.register(

View File

@@ -67,7 +67,7 @@ async def start_cryptobot_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -55,7 +55,7 @@ async def start_heleket_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_buttons = get_quick_amount_buttons(db_user.language)
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard

View File

@@ -8,6 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.utils.price_display import calculate_user_price, format_price_button
from app.utils.pricing_utils import format_period_description
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
@@ -27,34 +29,61 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
from app.localization.texts import get_texts
texts = get_texts(language)
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6]
periods = periods[:6] # Limit to 6 periods
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
base_price_kopeks = getattr(settings, price_attr)
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
callback_data = f"quick_amount_{price_info.final_price}"
# Format button text with discount display
period_label = f"{period} дней"
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f"{texts.format_price(price_info.base_price)}"
f"{texts.format_price(price_info.final_price)} "
f"(-{price_info.discount_percent}%) • {period_label}"
)
else:
button_text = f"{texts.format_price(price_info.final_price)}{period_label}"
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
text=button_text,
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows

View File

@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -264,7 +264,7 @@ async def start_pal24_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -42,7 +42,7 @@ async def start_stars_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -45,7 +45,7 @@ async def start_wata_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -48,7 +48,7 @@ async def start_yookassa_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -98,7 +98,7 @@ async def start_yookassa_sbp_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard

View File

@@ -187,7 +187,7 @@ async def handle_subscription_config_back(
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -202,7 +202,7 @@ async def handle_subscription_config_back(
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -277,7 +277,7 @@ async def _show_previous_configuration_step(
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)

View File

@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
if user is None:
return 0
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
if promo_group is None:
return 0

View File

@@ -350,7 +350,7 @@ async def _build_subscription_period_prompt(
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
promo_text = await _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,

View File

@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _build_promo_group_discount_text(
async def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
promo_group = db_user.get_primary_promo_group()
if not promo_group:
return ""

View File

@@ -104,6 +104,7 @@ from app.utils.pricing_utils import (
format_period_description,
apply_percentage_discount,
)
from app.utils.price_display import PriceInfo, format_price_text, calculate_user_price
from app.utils.subscription_utils import (
convert_subscription_link_to_happ_scheme,
get_display_subscription_link,
@@ -446,7 +447,7 @@ async def show_trial_offer(
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
devices_line=devices_line,
server_name=trial_server_name
)
@@ -647,7 +648,7 @@ async def start_subscription_purchase(
):
texts = get_texts(db_user.language)
keyboard = get_subscription_period_keyboard(db_user.language)
keyboard = get_subscription_period_keyboard(db_user.language, db_user)
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
await _edit_message_text_or_caption(
@@ -931,25 +932,21 @@ async def handle_extend_subscription(
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(days, 0)
period_discount_percent = db_user.get_promo_discount("period", days)
base_price, _ = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# 1. Calculate period price with promo group discount using unified system
base_price_original = PERIOD_PRICES.get(days, 0)
period_price_info = calculate_user_price(db_user, base_price_original, days, "period")
# 2. Calculate servers price with promo group discount
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount(
"servers",
days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period
servers_total_base = servers_price_per_month * months_in_period
servers_price_info = calculate_user_price(db_user, servers_total_base, days, "servers")
# 3. Calculate devices price with promo group discount
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
@@ -963,31 +960,34 @@ async def handle_extend_subscription(
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
"devices",
days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period
devices_total_base = devices_price_per_month * months_in_period
devices_price_info = calculate_user_price(db_user, devices_total_base, days, "devices")
# 4. Calculate traffic price with promo group discount
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
traffic_total_base = traffic_price_per_month * months_in_period
traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, "traffic")
# 5. Calculate ORIGINAL price (before ALL discounts)
total_original_price = (
base_price_original
+ servers_price_per_month * months_in_period
+ devices_price_per_month * months_in_period
+ traffic_price_per_month * months_in_period
period_price_info.base_price +
servers_price_info.base_price +
devices_price_info.base_price +
traffic_price_info.base_price
)
price = base_price + total_servers_price + total_devices_price + total_traffic_price
promo_component = _apply_promo_offer_discount(db_user, price)
# 6. Sum prices with promo group discounts applied
total_price = (
period_price_info.final_price +
servers_price_info.final_price +
devices_price_info.final_price +
traffic_price_info.final_price
)
# 7. Apply promo offer discount on top of promo group discounts
promo_component = _apply_promo_offer_discount(db_user, total_price)
# Store: original = price before discounts, final = price with all discounts
renewal_prices[days] = {
"final": promo_component["discounted"],
"original": total_original_price,
@@ -1018,23 +1018,27 @@ async def handle_extend_subscription(
final_price = price_info
original_price = final_price
has_discount = original_price > final_price
period_display = format_period_description(days, db_user.language)
if has_discount:
prices_text += (
"📅 "
f"{period_display} - <s>{texts.format_price(original_price)}</s> "
f"{texts.format_price(final_price)}\n"
)
else:
prices_text += (
"📅 "
f"{period_display} - {texts.format_price(final_price)}\n"
)
# Calculate discount percentage for PriceInfo
discount_percent = 0
if original_price > final_price and original_price > 0:
discount_percent = ((original_price - final_price) * 100) // original_price
promo_discounts_text = _build_promo_group_discount_text(
# Create PriceInfo and format text using unified system
price_info_obj = PriceInfo(
base_price=original_price,
final_price=final_price,
discount_percent=discount_percent
)
prices_text += format_price_text(
period_label=period_display,
price_info=price_info_obj,
format_price_func=texts.format_price
) + "\n"
promo_discounts_text = await _build_promo_group_discount_text(
db_user,
available_periods,
texts=texts,

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

@@ -13,6 +13,7 @@ from app.utils.pricing_utils import (
format_period_description,
apply_percentage_discount,
)
from app.utils.price_display import PriceInfo, format_price_button
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
@@ -863,36 +864,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_subscription_period_keyboard(
language: str = DEFAULT_LANGUAGE,
user: Optional[User] = None
) -> InlineKeyboardMarkup:
"""
Generate subscription period selection keyboard with personalized pricing.
Args:
language: User's language code
user: User object for personalized discounts (None = default discounts)
Returns:
InlineKeyboardMarkup with period buttons showing personalized prices
"""
from app.utils.price_display import calculate_user_price
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_subscription_periods()
period_texts = {
14: texts.PERIOD_14_DAYS,
30: texts.PERIOD_30_DAYS,
60: texts.PERIOD_60_DAYS,
90: texts.PERIOD_90_DAYS,
180: texts.PERIOD_180_DAYS,
360: texts.PERIOD_360_DAYS
}
for days in available_periods:
if days in period_texts:
keyboard.append([
InlineKeyboardButton(
text=period_texts[days],
callback_data=f"period_{days}"
)
])
# Get base price for this period
base_price = PERIOD_PRICES.get(days, 0)
# Calculate personalized price with user's discounts
price_info = calculate_user_price(user, base_price, days, "period")
# Format period description
period_display = format_period_description(days, language)
# Format button text with discount display
button_text = format_price_button(
period_label=period_display,
price_info=price_info,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=button_text,
callback_data=f"period_{days}"
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1408,27 +1432,8 @@ def _get_days_word(days: int) -> str:
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
periods = [
(14, texts.PERIOD_14_DAYS),
(30, texts.PERIOD_30_DAYS),
(60, texts.PERIOD_60_DAYS),
(90, texts.PERIOD_90_DAYS)
]
for days, text in periods:
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
# Deprecated: get_extend_subscription_keyboard() was removed.
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
def get_add_traffic_keyboard(
@@ -1965,15 +1970,39 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
if isinstance(price_info, dict):
final_price = price_info.get("final")
original_price = price_info.get("original", 0)
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
original_price = price_info
period_display = format_period_description(days, language)
# Create PriceInfo from already calculated prices
# Note: original_price and final_price are calculated in the handler
discount_percent = 0
if original_price > final_price and original_price > 0:
discount_percent = ((original_price - final_price) * 100) // original_price
price_info_obj = PriceInfo(
base_price=original_price,
final_price=final_price,
discount_percent=discount_percent
)
# Format button using unified system
button_text = format_price_button(
period_label=period_display,
price_info=price_info_obj,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(final_price)}",
text=button_text,
callback_data=f"extend_period_{days}"
)
])

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:",
@@ -1383,7 +1395,7 @@
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡ Activate before the trial ends!\n",

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": "Доступные категории:",
@@ -1403,7 +1415,7 @@
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡ Успейте оформить до окончания тестового периода!\n",

View File

@@ -5,7 +5,6 @@ import logging
from typing import Any, Dict
from app.config import settings
from app.utils.pricing_utils import format_period_option_label
from app.localization.loader import (
DEFAULT_LANGUAGE,
clear_locale_cache,
@@ -31,12 +30,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "ru":
return {
"PERIOD_14_DAYS": format_period_option_label("📅 14 дней", settings.PRICE_14_DAYS),
"PERIOD_30_DAYS": format_period_option_label("📅 30 дней", settings.PRICE_30_DAYS),
"PERIOD_60_DAYS": format_period_option_label("📅 60 дней", settings.PRICE_60_DAYS),
"PERIOD_90_DAYS": format_period_option_label("📅 90 дней", settings.PRICE_90_DAYS),
"PERIOD_180_DAYS": format_period_option_label("📅 180 дней", settings.PRICE_180_DAYS),
"PERIOD_360_DAYS": format_period_option_label("📅 360 дней", settings.PRICE_360_DAYS),
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
@@ -56,12 +49,6 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
if language_code == "en":
return {
"PERIOD_14_DAYS": format_period_option_label("📅 14 days", settings.PRICE_14_DAYS),
"PERIOD_30_DAYS": format_period_option_label("📅 30 days", settings.PRICE_30_DAYS),
"PERIOD_60_DAYS": format_period_option_label("📅 60 days", settings.PRICE_60_DAYS),
"PERIOD_90_DAYS": format_period_option_label("📅 90 days", settings.PRICE_90_DAYS),
"PERIOD_180_DAYS": format_period_option_label("📅 180 days", settings.PRICE_180_DAYS),
"PERIOD_360_DAYS": format_period_option_label("📅 360 days", settings.PRICE_360_DAYS),
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",

View File

@@ -221,7 +221,7 @@ class AdminNotificationService:
⏰ <b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}

View File

@@ -211,7 +211,7 @@ class CryptoBotPaymentMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (

View File

@@ -335,7 +335,7 @@ class HeleketPaymentMixin:
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
referrer_info = format_referrer_info(user)
subscription = getattr(user, "subscription", None)
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
try:
from app.services.admin_notification_service import AdminNotificationService

View File

@@ -281,13 +281,8 @@ class MulenPayPaymentMixin:
)
return False
# Используем предзагруженные значения для избежания lazy-загрузки
promo_group = (
user.promo_group if hasattr(user, "promo_group") and user.promo_group else None
)
subscription = (
user.subscription if hasattr(user, "subscription") and user.subscription else None
)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = (
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"

View File

@@ -362,7 +362,7 @@ class Pal24PaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"

View File

@@ -430,7 +430,7 @@ class TelegramStarsMixin:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"

View File

@@ -464,7 +464,7 @@ class WataPaymentMixin:
await db.commit()
await db.refresh(user)
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"

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

@@ -50,7 +50,7 @@ def _resolve_addon_discount_percent(
*,
period_days: Optional[int] = None,
) -> int:
group = promo_group or (getattr(user, "promo_group", None) if user else None)
group = promo_group or (user.get_primary_promo_group() if user else None)
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
return 0
@@ -470,7 +470,7 @@ class SubscriptionService:
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
traffic_price = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
@@ -570,7 +570,7 @@ class SubscriptionService:
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,
@@ -798,7 +798,7 @@ class SubscriptionService:
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
@@ -910,7 +910,7 @@ class SubscriptionService:
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
promo_group = promo_group or (user.get_primary_promo_group() if user else None)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads,

View File

@@ -131,7 +131,7 @@ class TributeService:
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"

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

187
app/utils/price_display.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Unified price display system for all subscription and balance pricing.
This module provides a centralized way to:
- Calculate prices with all applicable discounts (promo groups, promo offers)
- Format price buttons consistently across all flows
- Ensure uniform discount display throughout the application
"""
from dataclasses import dataclass
from typing import Optional
import logging
from app.database.models import User
from app.config import settings
logger = logging.getLogger(__name__)
@dataclass
class PriceInfo:
"""Container for pricing information with discounts."""
base_price: int # Original price without any discounts (kopeks)
final_price: int # Final price after all discounts (kopeks)
discount_percent: int # Total discount percentage
@property
def has_discount(self) -> bool:
"""Check if there's any discount applied."""
return self.base_price > self.final_price and self.discount_percent > 0
@property
def discount_value(self) -> int:
"""Get the absolute discount value in kopeks."""
return self.base_price - self.final_price
def calculate_user_price(
user: Optional[User],
base_price: int,
period_days: int,
category: str = "period"
) -> PriceInfo:
"""
Calculate final price for a user with all applicable discounts.
Args:
user: User object (None for base/default pricing from settings)
base_price: Base price without discounts (kopeks)
period_days: Subscription period in days
category: Discount category ("period", "servers", "devices", "traffic")
Returns:
PriceInfo with base_price, final_price, and discount_percent
Example:
>>> user = get_user_from_db(123)
>>> price_info = calculate_user_price(user, 100000, 30, "period")
>>> print(f"{price_info.base_price} -> {price_info.final_price} ({price_info.discount_percent}%)")
100000 -> 80000 (20%)
>>> # For base pricing (no user)
>>> price_info = calculate_user_price(None, 100000, 30, "period")
>>> # Uses BASE_PROMO_GROUP_PERIOD_DISCOUNTS from settings
"""
if not base_price or base_price <= 0:
return PriceInfo(base_price=base_price or 0, final_price=base_price or 0, discount_percent=0)
# Get discount percentage
if user:
# Get user's promo group discount for this category
discount_percent = user.get_promo_discount(category, period_days)
else:
# For None user, use base settings discount (only for period category)
if category == "period":
discount_percent = settings.get_base_promo_group_period_discount(period_days)
else:
discount_percent = 0
logger.debug(
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
f"base_price={base_price}, period_days={period_days}, category={category}, "
f"discount_percent={discount_percent}"
)
if discount_percent <= 0:
return PriceInfo(base_price=base_price, final_price=base_price, discount_percent=0)
# Calculate discounted price
discount_value = (base_price * discount_percent) // 100
final_price = base_price - discount_value
logger.debug(
f"Calculated price for user {user.telegram_id if user else 'None'}: "
f"{base_price} -> {final_price} (-{discount_percent}%) "
f"[category={category}, period={period_days}]"
)
return PriceInfo(
base_price=base_price,
final_price=final_price,
discount_percent=discount_percent
)
def format_price_button(
period_label: str,
price_info: PriceInfo,
format_price_func,
emphasize: bool = False,
add_exclamation: bool = True
) -> str:
"""
Format a price button text with unified discount display.
Args:
period_label: Label for the period (e.g., "30 дней", "1 месяц")
price_info: PriceInfo object with pricing details
format_price_func: Function to format price (usually texts.format_price)
emphasize: Add fire emojis for emphasis (for best deals)
add_exclamation: Add exclamation mark after discount percent
Returns:
Formatted button text
Examples:
With discount:
"📅 30 дней - 990₽ ➜ 693₽ (-30%)!"
With emphasis:
"🔥 📅 360 дней - 8990₽ ➜ 6293₽ (-30%)! 🔥"
Without discount:
"📅 30 дней - 990₽"
"""
# Build button text with or without discount
if price_info.has_discount:
exclamation = "!" if add_exclamation else ""
button_text = (
f"📅 {period_label} - "
f"{format_price_func(price_info.base_price)}"
f"{format_price_func(price_info.final_price)} "
f"(-{price_info.discount_percent}%){exclamation}"
)
else:
button_text = f"📅 {period_label} - {format_price_func(price_info.final_price)}"
# Add emphasis for best deals
if emphasize:
button_text = f"🔥 {button_text} 🔥"
logger.debug(f"Formatted button: {button_text}")
return button_text
def format_price_text(
period_label: str,
price_info: PriceInfo,
format_price_func
) -> str:
"""
Format a price for message text (not button) with unified discount display.
Args:
period_label: Label for the period (e.g., "30 дней")
price_info: PriceInfo object with pricing details
format_price_func: Function to format price (usually texts.format_price)
Returns:
Formatted price text for messages
Examples:
With discount:
"📅 30 дней - 990₽ ➜ 693₽"
Without discount:
"📅 30 дней - 990₽"
"""
if price_info.has_discount:
return (
f"📅 {period_label} - "
f"{format_price_func(price_info.base_price)}"
f"{format_price_func(price_info.final_price)}"
)
else:
return f"📅 {period_label} - {format_price_func(price_info.final_price)}"

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,21 +313,6 @@ def format_period_description(days: int, language: str = "ru") -> str:
return f"{days} days ({months} {month_word})"
def format_period_option_label(label: str, price: int) -> str:
"""Return a period option label with price when it's greater than zero.
When the price is zero or negative, the price suffix is omitted so that the
option does not misleadingly show "0" as the cost of the period. This keeps
the UI consistent when pricing is calculated dynamically based on other
parameters such as servers or devices.
"""
if price and price > 0:
return f"{label} - {settings.format_price(price)}"
return label
def validate_pricing_calculation(
base_price: int,
monthly_additions: int,

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

@@ -203,6 +203,10 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions(
self.language = "ru"
self.promo_group = None
self.subscription = None
self.user_promo_groups = []
def get_primary_promo_group(self):
return self.promo_group
dummy_user = DummyUser()

View File

@@ -208,6 +208,7 @@ async def test_process_mulenpay_callback_success(
referred_by_id=None,
referrer=None,
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user
@@ -302,6 +303,7 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
referred_by_id=None,
referrer=None,
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user_crypto(db, user_id):
return user
@@ -431,6 +433,7 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch)
referrer=None,
language="ru",
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user if user_id == user.id else None
@@ -538,6 +541,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
referred_by_id=None,
referrer=None,
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user
@@ -666,6 +670,7 @@ async def test_process_yookassa_webhook_restores_missing_payment(
referred_by_id=None,
referrer=None,
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user
@@ -816,6 +821,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
referrer=None,
language="ru",
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user
@@ -982,6 +988,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
referrer=None,
language="ru",
)
user.get_primary_promo_group = lambda: getattr(user, "promo_group", None)
async def fake_get_user(db, user_id):
return user

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()
@@ -82,7 +81,7 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch):
db.rollback = rollback_mock
monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock)
monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock)
monkeypatch.setattr(
"app.services.remnawave_service.get_user_by_telegram_id",
get_user_mock,
@@ -97,7 +96,6 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch):
rollback_mock.assert_awaited()
@pytest.mark.asyncio
async def test_get_or_create_user_creates_new(monkeypatch):
service = _create_service()
db = AsyncMock()
@@ -107,7 +105,7 @@ async def test_get_or_create_user_creates_new(monkeypatch):
create_user_mock = AsyncMock(return_value=new_user)
monkeypatch.setattr("app.services.remnawave_service.create_user", create_user_mock)
monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock)
user, created = await service._get_or_create_bot_user_from_panel(db, panel_user)
@@ -117,6 +115,7 @@ async def test_get_or_create_user_creates_new(monkeypatch):
db=db,
telegram_id=777,
username="new_user",
first_name="Panel User 777",
first_name="User 777",
last_name=None,
language="ru",
)

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)
@@ -35,6 +34,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = None
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"period_days": 30,
@@ -185,7 +185,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
@pytest.mark.asyncio
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
@@ -204,6 +203,7 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = subscription
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"cart_mode": "extend",
@@ -298,3 +298,406 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
bot.send_message.assert_awaited()
service_mock.update_remnawave_user.assert_awaited()
create_transaction_mock.assert_awaited()
async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch):
"""Тест: триал сохраняется, если не хватает денег для автопокупки"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 123
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 99
user.telegram_id = 9999
# ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243),
# но subtract_user_balance вернёт False (симуляция неудачи списания)
user.balance_kopeks = 60_000
user.language = "ru"
user.subscription = subscription
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 50_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: недостаточно денег, списание не удалось
subtract_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt) if dt else "",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is False # Автопокупка не удалась
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
subtract_mock.assert_awaited_once()
async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch):
"""Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 456
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=1)
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 88
user.telegram_id = 8888
user.balance_kopeks = 200_000 # Достаточно денег
user.language = "ru"
user.subscription = subscription
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 2,
"traffic_limit_gb": 500,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: деньги списались успешно
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: продление успешно
async def extend_stub(db, current_subscription, days):
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
return current_subscription
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_stub,
)
create_transaction_mock = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.create_transaction",
create_transaction_mock,
)
service_mock = MagicMock()
service_mock.update_remnawave_user = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.SubscriptionService",
lambda: service_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
# ИСПРАВЛЕНО: Добавлен мок для format_local_datetime
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt) if dt else "",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is True # Автопокупка успешна
assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН!
assert subscription.status == "active"
db_session.commit.assert_awaited() # Commit был вызван
async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch):
"""Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 789
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=3)
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 77
user.telegram_id = 7777
user.balance_kopeks = 200_000 # Достаточно денег
user.language = "ru"
user.subscription = subscription
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: деньги списались успешно
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: extend_subscription выбрасывает ошибку!
async def extend_error(db, current_subscription, days):
raise Exception("Database connection error")
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_error,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
# ИСПРАВЛЕНО: Добавлены недостающие моки
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt) if dt else "",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is False # Автопокупка не удалась
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
db_session.rollback.assert_awaited() # ROLLBACK БЫЛ ВЫЗВАН!
async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
"""Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено!
now = datetime.utcnow()
trial_end = now + timedelta(days=2) # Осталось 2 дня триала
subscription = MagicMock()
subscription.id = 321
subscription.is_trial = True
subscription.status = "active"
subscription.end_date = trial_end
subscription.start_date = now - timedelta(days=1) # Триал начался вчера
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 66
user.telegram_id = 6666
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = subscription
user.get_primary_promo_group = MagicMock(return_value=None)
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30, # Покупает 30 дней
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: extend_subscription с логикой переноса бонусных дней
# Имитируем нашу новую логику из extend_subscription()
async def extend_with_bonus(db, current_subscription, days):
# Вычисляем бонусные дни (как в нашем коде)
bonus_days = 0
if current_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
if current_subscription.end_date and current_subscription.end_date > now:
remaining = current_subscription.end_date - now
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
total_days = days + bonus_days
current_subscription.end_date = current_subscription.end_date + timedelta(days=total_days)
return current_subscription
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_with_bonus,
)
create_transaction_mock = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.create_transaction",
create_transaction_mock,
)
service_mock = MagicMock()
service_mock.update_remnawave_user = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.SubscriptionService",
lambda: service_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt),
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock()
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is True
assert subscription.is_trial is False # Триал конвертирован
# Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных)
# end_date должна быть примерно на 32 дня от оригинального trial_end
expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2)
actual_delta = (subscription.end_date - trial_end).days
assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}"

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

@@ -31,6 +31,11 @@ def mock_user():
user.balance_kopeks = 10000
user.subscription = None
user.has_had_paid_subscription = False
user.promo_group_id = None
user.get_primary_promo_group = MagicMock(return_value=None)
user.get_promo_discount = MagicMock(return_value=0)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_expires_at = None
return user
@pytest.fixture
@@ -54,7 +59,6 @@ def mock_state():
state.clear = AsyncMock()
return state
@pytest.mark.asyncio
async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест сохранения корзины и перенаправления к пополнению"""
# Мокаем все зависимости
@@ -102,7 +106,6 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state,
# mock_callback_query.answer не должен быть вызван
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с достаточным балансом"""
# Подготовим данные корзины
@@ -121,10 +124,13 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \
patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \
patch('app.localization.texts.get_texts') as mock_get_texts, \
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func:
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \
patch('app.handlers.subscription.purchase._prepare_subscription_summary') as mock_prepare_summary:
# Подготовим моки
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
mock_cart_service.save_user_cart = AsyncMock(return_value=True)
mock_prepare_summary.return_value = ("summary", {})
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
mock_format_period.return_value = "30 дней"
mock_keyboard = InlineKeyboardMarkup(
@@ -143,8 +149,8 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
# Вызываем функцию
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
# Проверяем, что данные были загружены из корзины и установлены в FSM
mock_state.set_data.assert_called_once_with(cart_data)
# Проверяем, что корзина была загружена
mock_cart_service.get_user_cart.assert_called_once_with(mock_user.id)
# Проверяем, что сообщение было отредактировано
mock_callback_query.message.edit_text.assert_called_once()
@@ -153,7 +159,6 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_skips_edit_when_message_matches(
mock_callback_query,
mock_state,
@@ -224,7 +229,6 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches(
mock_cart_service.save_user_cart.assert_not_called()
@pytest.mark.asyncio
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
mock_callback_query,
mock_state,
@@ -299,7 +303,6 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled(
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
# Подготовим данные корзины
@@ -320,6 +323,7 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock
# Подготовим моки
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
mock_cart_service.save_user_cart = AsyncMock(return_value=True)
mock_keyboard = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="Пополнить", callback_data="topup")]]
)
@@ -347,7 +351,6 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock
# (ответ отправляется через return до вызова callback.answer())
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест очистки сохраненной корзины"""
# Мокаем все зависимости
@@ -369,7 +372,6 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock
# Проверяем, что вызван answer
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Отмена покупки должна очищать сохраненную корзину"""
mock_clear_draft = AsyncMock()

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,52 @@
"""
Тесты для утилит ценообразования и форматирования цен.
Этот модуль тестирует функции из app/utils/pricing_utils.py и app/localization/texts.py,
особенно функции отображения цен со скидками на кнопках подписки.
"""
import pytest
from unittest.mock import patch, MagicMock
from typing import Dict, Any
from app.localization.texts import _build_dynamic_values
# DEPRECATED: format_period_option_label tests removed - function replaced with unified price_display system
class TestBuildDynamicValues:
"""
Тесты для функции _build_dynamic_values из texts.py.
NOTE: PERIOD_*_DAYS константы были удалены из _build_dynamic_values,
так как теперь кнопки периодов генерируются динамически в get_subscription_period_keyboard()
с учетом персональных скидок пользователя.
"""
@patch('app.localization.texts.settings')
def test_returns_empty_dict_for_unknown_language(self, mock_settings: MagicMock) -> None:
"""Неизвестный язык должен возвращать пустой словарь."""
result = _build_dynamic_values("fr-FR") # Французский не поддерживается
assert result == {}
@patch('app.localization.texts.settings')
def test_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None:
"""Должны генерироваться ключи трафика и другие динамические значения."""
# Настройка моков для traffic цен
mock_settings.format_price = lambda x: f"{x // 100}"
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# Проверяем наличие ключей трафика
assert "TRAFFIC_5GB" in result
assert "TRAFFIC_10GB" in result
assert "TRAFFIC_UNLIMITED" in result
assert "SUPPORT_INFO" in result