Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl

Revert "Fix server category button routing in admin panel"
This commit is contained in:
Egor
2025-11-09 05:55:16 +03:00
committed by GitHub
9 changed files with 24 additions and 805 deletions

View File

@@ -1,57 +0,0 @@
from typing import List, Optional
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import ServerCategory
async def get_all_server_categories(db: AsyncSession, include_inactive: bool = False) -> List[ServerCategory]:
query = select(ServerCategory).order_by(ServerCategory.sort_order, ServerCategory.name)
if not include_inactive:
query = query.where(ServerCategory.is_active.is_(True))
result = await db.execute(query)
return result.scalars().all()
async def get_server_category_by_id(db: AsyncSession, category_id: int) -> Optional[ServerCategory]:
result = await db.execute(
select(ServerCategory).where(ServerCategory.id == category_id)
)
return result.scalar_one_or_none()
async def create_server_category(
db: AsyncSession,
name: str,
sort_order: int = 0,
is_active: bool = True,
) -> ServerCategory:
category = ServerCategory(name=name, sort_order=sort_order, is_active=is_active)
db.add(category)
await db.commit()
await db.refresh(category)
return category
async def update_server_category(
db: AsyncSession,
category_id: int,
**updates,
) -> Optional[ServerCategory]:
valid_fields = {"name", "sort_order", "is_active"}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
if not filtered_updates:
return await get_server_category_by_id(db, category_id)
await db.execute(
update(ServerCategory).where(ServerCategory.id == category_id).values(**filtered_updates)
)
await db.commit()
return await get_server_category_by_id(db, category_id)
async def delete_server_category(db: AsyncSession, category_id: int) -> bool:
await db.execute(delete(ServerCategory).where(ServerCategory.id == category_id))
await db.commit()
return True

View File

@@ -19,7 +19,6 @@ from sqlalchemy.orm import selectinload
from app.database.models import (
PromoGroup,
ServerCategory,
ServerSquad,
SubscriptionServer,
Subscription,
@@ -49,7 +48,6 @@ async def create_server_squad(
is_available: bool = True,
is_trial_eligible: bool = False,
sort_order: int = 0,
category_id: Optional[int] = None,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -73,13 +71,6 @@ async def create_server_squad(
"Не все промогруппы найдены при создании сервера %s", display_name
)
if category_id is not None:
category_exists = await db.execute(
select(ServerCategory.id).where(ServerCategory.id == category_id)
)
if category_exists.scalar_one_or_none() is None:
raise ValueError("Server category not found")
server_squad = ServerSquad(
squad_uuid=squad_uuid,
display_name=display_name,
@@ -91,7 +82,6 @@ async def create_server_squad(
is_available=is_available,
is_trial_eligible=is_trial_eligible,
sort_order=sort_order,
category_id=category_id,
allowed_promo_groups=promo_groups,
)
@@ -110,10 +100,7 @@ async def get_server_squad_by_uuid(
result = await db.execute(
select(ServerSquad)
.options(
selectinload(ServerSquad.allowed_promo_groups),
selectinload(ServerSquad.category),
)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.squad_uuid == squad_uuid)
)
return result.scalars().unique().one_or_none()
@@ -126,10 +113,7 @@ async def get_server_squad_by_id(
result = await db.execute(
select(ServerSquad)
.options(
selectinload(ServerSquad.allowed_promo_groups),
selectinload(ServerSquad.category),
)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
return result.scalars().unique().one_or_none()
@@ -142,8 +126,8 @@ async def get_all_server_squads(
limit: int = 50
) -> Tuple[List[ServerSquad], int]:
query = select(ServerSquad).options(selectinload(ServerSquad.category))
query = select(ServerSquad)
if available_only:
query = query.where(ServerSquad.is_available == True)
@@ -167,16 +151,11 @@ async def get_all_server_squads(
async def get_available_server_squads(
db: AsyncSession,
promo_group_id: Optional[int] = None,
category_id: Optional[int] = None,
only_with_capacity: bool = False,
) -> List[ServerSquad]:
query = (
select(ServerSquad)
.options(
selectinload(ServerSquad.allowed_promo_groups),
selectinload(ServerSquad.category),
)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.is_available.is_(True))
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
@@ -186,21 +165,8 @@ async def get_available_server_squads(
PromoGroup.id == promo_group_id
)
if category_id is not None:
query = query.where(ServerSquad.category_id == category_id)
result = await db.execute(query)
squads = result.scalars().unique().all()
if only_with_capacity:
filtered: List[ServerSquad] = []
for squad in squads:
if squad.is_full:
continue
filtered.append(squad)
return filtered
return squads
return result.scalars().unique().all()
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
@@ -255,35 +221,6 @@ async def get_random_active_squad_uuid(
return fallback_uuid
async def choose_least_loaded_server_in_category(
db: AsyncSession,
category_id: int,
promo_group_id: Optional[int] = None,
) -> Optional[ServerSquad]:
"""Выбирает наименее загруженный сервер в категории."""
category_squads = await get_available_server_squads(
db,
promo_group_id=promo_group_id,
category_id=category_id,
only_with_capacity=True,
)
if not category_squads:
return None
def load_key(squad: ServerSquad) -> tuple:
max_users = squad.max_users or 0
current_users = squad.current_users or 0
if max_users:
ratio = current_users / max_users
else:
ratio = 0
return (ratio, current_users, squad.id)
return min(category_squads, key=load_key)
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
@@ -334,20 +271,10 @@ async def update_server_squad(
"is_available",
"sort_order",
"is_trial_eligible",
"category_id",
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
if "category_id" in filtered_updates:
category_id = filtered_updates["category_id"]
if category_id is not None:
category_exists = await db.execute(
select(ServerCategory.id).where(ServerCategory.id == category_id)
)
if category_exists.scalar_one_or_none() is None:
raise ValueError("Server category not found")
if not filtered_updates:
return None

View File

@@ -1281,22 +1281,6 @@ class PollAnswer(Base):
)
class ServerCategory(Base):
__tablename__ = "server_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, unique=True)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
servers = relationship("ServerSquad", back_populates="category")
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<ServerCategory(id={self.id}, name={self.name!r})>"
class ServerSquad(Base):
__tablename__ = "server_squads"
@@ -1318,10 +1302,8 @@ class ServerSquad(Base):
description = Column(Text, nullable=True)
sort_order = Column(Integer, default=0)
category_id = Column(Integer, ForeignKey("server_categories.id", ondelete="SET NULL"), nullable=True)
max_users = Column(Integer, nullable=True)
max_users = Column(Integer, nullable=True)
current_users = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
@@ -1333,8 +1315,6 @@ class ServerSquad(Base):
back_populates="server_squads",
lazy="selectin",
)
category = relationship("ServerCategory", back_populates="servers")
@property
def price_rubles(self) -> float:

View File

@@ -3053,63 +3053,6 @@ async def ensure_server_promo_groups_setup() -> bool:
return False
async def ensure_server_categories_table() -> bool:
logger.info("=== ПРОВЕРКА ТАБЛИЦЫ КАТЕГОРИЙ СЕРВЕРОВ ===")
try:
table_exists = await check_table_exists("server_categories")
if table_exists:
logger.info(" Таблица server_categories уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE server_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE server_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
else:
create_sql = """
CREATE TABLE server_categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
sort_order INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица server_categories создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы server_categories: {error}")
return False
async def add_server_trial_flag_column() -> bool:
column_exists = await check_column_exists('server_squads', 'is_trial_eligible')
if column_exists:
@@ -3144,53 +3087,6 @@ async def add_server_trial_flag_column() -> bool:
return False
async def add_server_category_column() -> bool:
column_exists = await check_column_exists('server_squads', 'category_id')
if column_exists:
logger.info("Колонка category_id уже существует в server_squads")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
column_def = 'INTEGER'
await conn.execute(
text(f"ALTER TABLE server_squads ADD COLUMN category_id {column_def}")
)
if db_type == 'postgresql':
await conn.execute(
text(
"ALTER TABLE server_squads ADD CONSTRAINT "
"fk_server_squads_category FOREIGN KEY (category_id) "
"REFERENCES server_categories(id) ON DELETE SET NULL"
)
)
elif db_type != 'sqlite':
await conn.execute(
text(
"ALTER TABLE server_squads ADD CONSTRAINT fk_server_squads_category "
"FOREIGN KEY (category_id) REFERENCES server_categories(id) ON DELETE SET NULL"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_server_squads_category_id "
"ON server_squads(category_id)"
)
)
logger.info("✅ Добавлена колонка category_id в server_squads")
return True
except Exception as error:
logger.error(f"Ошибка добавления колонки category_id: {error}")
return False
async def create_system_settings_table() -> bool:
table_exists = await check_table_exists("system_settings")
if table_exists:
@@ -4143,18 +4039,6 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
categories_ready = await ensure_server_categories_table()
if categories_ready:
logger.info("✅ Таблица категорий серверов готова")
else:
logger.warning("⚠️ Проблемы с созданием таблицы категорий серверов")
category_column_ready = await add_server_category_column()
if category_column_ready:
logger.info("✅ Колонка категории для серверов настроена")
else:
logger.warning("⚠️ Проблемы с добавлением колонки категории для серверов")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
@@ -4231,8 +4115,6 @@ async def check_migration_status():
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
"server_categories_table": False,
"server_squads_category_column": False,
"privacy_policies_table": False,
"public_offers_table": False,
"users_promo_group_column": False,
@@ -4266,8 +4148,6 @@ async def check_migration_status():
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
status["server_categories_table"] = await check_table_exists('server_categories')
status["server_squads_category_column"] = await check_column_exists('server_squads', 'category_id')
status["discount_offers_table"] = await check_table_exists('discount_offers')
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
@@ -4324,8 +4204,6 @@ async def check_migration_status():
"promo_groups_table": "Таблица промо-групп",
"server_promo_groups_table": "Связи серверов и промогрупп",
"server_squads_trial_column": "Колонка триального назначения у серверов",
"server_categories_table": "Таблица категорий серверов",
"server_squads_category_column": "Колонка категории у серверов",
"users_promo_group_column": "Колонка promo_group_id у пользователей",
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",

View File

@@ -1,7 +1,5 @@
import html
import logging
from typing import List
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,10 +18,6 @@ from app.database.crud.server_squad import (
update_server_squad_promo_groups,
get_server_connected_users,
)
from app.database.crud.server_category import (
get_all_server_categories,
create_server_category,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
from app.utils.decorators import admin_required, error_handler
@@ -42,7 +36,6 @@ def _build_server_edit_view(server):
)
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
category_name = getattr(getattr(server, "category", None), "name", None) or "Не указана"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -60,7 +53,6 @@ def _build_server_edit_view(server):
• Лимит пользователей: {server.max_users or 'Без лимита'}
• Текущих пользователей: {server.current_users}
• Промогруппы: {promo_groups_text}
• Категория: {category_name}
• Выдача триала: {trial_status}
<b>Описание:</b>
@@ -105,11 +97,6 @@ def _build_server_edit_view(server):
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🏷 Категория", callback_data=f"admin_server_edit_category_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
@@ -1087,225 +1074,6 @@ async def process_server_description_edit(
await message.answer("❌ Ошибка при обновлении сервера")
@admin_required
@error_handler
async def show_server_category_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
server_id = int(callback.data.split("_")[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
categories = await get_all_server_categories(db)
current_category = getattr(getattr(server, "category", None), "name", None)
safe_current = html.escape(current_category or "Не выбрана")
text_lines = [
"🏷 <b>Категория сервера</b>",
"",
f"Текущая категория: <b>{safe_current}</b>",
"",
"Выберите категорию из списка или создайте новую:",
]
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
for category in categories:
emoji = "" if server.category_id == category.id else "⚪️"
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"{emoji} {category.name}",
callback_data=f"admin_server_category_set_{server.id}_{category.id}",
)
]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=" Создать категорию",
callback_data=f"admin_server_category_create_{server.id}",
)
]
)
if server.category_id:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text="🗑 Удалить категорию",
callback_data=f"admin_server_category_clear_{server.id}",
)
]
)
keyboard_rows.append(
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"admin_server_edit_{server.id}",
)
]
)
await callback.message.edit_text(
"\n".join(text_lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def set_server_category(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
parts = callback.data.split("_")
server_id = int(parts[-2])
category_id = int(parts[-1])
server = await update_server_squad(db, server_id, category_id=category_id)
if not server:
await callback.answer("Не удалось обновить категорию", show_alert=True)
return
await cache.delete_pattern("available_countries*")
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer("✅ Категория обновлена")
@admin_required
@error_handler
async def clear_server_category(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
server_id = int(callback.data.split("_")[-1])
server = await update_server_squad(db, server_id, category_id=None)
if not server:
await callback.answer("Не удалось обновить категорию", show_alert=True)
return
await cache.delete_pattern("available_countries*")
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer("✅ Категория удалена")
@admin_required
@error_handler
async def start_server_category_creation(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
server_id = int(callback.data.split("_")[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
await state.set_data({"server_id": server_id})
await state.set_state(AdminStates.creating_server_category)
await callback.message.edit_text(
"🏷 <b>Новая категория</b>\n\n"
"Отправьте название новой категории для сервера:",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data=f"admin_server_edit_category_{server_id}"
)
]
]
),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def process_server_category_creation(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
data = await state.get_data()
server_id = data.get("server_id")
if not server_id:
await message.answer("❌ Сервер не найден")
return
category_name = (message.text or "").strip()
if not (2 <= len(category_name) <= 255):
await message.answer("❌ Название категории должно содержать от 2 до 255 символов")
return
try:
category = await create_server_category(db, name=category_name)
except Exception as error:
logger.error("Не удалось создать категорию %s: %s", category_name, error)
await message.answer("Не удалось создать категорию. Попробуйте другое название")
return
server = await update_server_squad(db, server_id, category_id=category.id)
await state.clear()
await cache.delete_pattern("available_countries*")
if not server:
await message.answer("❌ Категория создана, но не удалось обновить сервер")
return
safe_name = html.escape(category.name)
await message.answer(
f"✅ Категория <b>{safe_name}</b> создана и назначена серверу",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}"
)
]
]
),
parse_mode="HTML",
)
@admin_required
@error_handler
async def start_server_edit_promo_groups(
@@ -1526,8 +1294,7 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("country")
& ~F.data.contains("limit")
& ~F.data.contains("desc")
& ~F.data.contains("promo")
& ~F.data.contains("category"),
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
@@ -1537,19 +1304,14 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
dp.callback_query.register(show_server_category_menu, F.data.startswith("admin_server_edit_category_"))
dp.callback_query.register(set_server_category, F.data.startswith("admin_server_category_set_"))
dp.callback_query.register(clear_server_category, F.data.startswith("admin_server_category_clear_"))
dp.callback_query.register(start_server_category_creation, F.data.startswith("admin_server_category_create_"))
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
dp.message.register(process_server_name_edit, AdminStates.editing_server_name)
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
dp.message.register(process_server_category_creation, AdminStates.creating_server_category)
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))

View File

@@ -21,7 +21,7 @@ from app.database.models import (
User, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
SubscriptionConversion, SentNotification, BroadcastHistory,
ServerCategory, ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
@@ -68,7 +68,6 @@ class BackupService:
SystemSetting,
ServiceRule,
Squad,
ServerCategory,
ServerSquad,
PromoGroup,
User,
@@ -727,7 +726,7 @@ class BackupService:
"mulenpay_payments", "pal24_payments",
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"server_squads", "server_categories", "squads", "service_rules",
"server_squads", "squads", "service_rules",
"system_settings", "web_api_tokens", "monitoring_logs"
]

View File

@@ -12,7 +12,6 @@ from app.database.crud.server_squad import (
get_available_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_uuid,
choose_least_loaded_server_in_category,
)
from app.database.crud.subscription import (
add_subscription_servers,
@@ -104,8 +103,6 @@ class PurchaseServerOption:
original_price_label: Optional[str] = None
discount_percent: int = 0
is_available: bool = True
category_id: Optional[int] = None
is_category: bool = False
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
@@ -122,10 +119,6 @@ class PurchaseServerOption:
payload["original_price_label"] = self.original_price_label
if self.discount_percent:
payload["discount_percent"] = self.discount_percent
if self.category_id is not None:
payload["category_id"] = self.category_id
if self.is_category:
payload["is_category"] = True
return payload
@@ -136,8 +129,6 @@ class PurchaseServersConfig:
max_selectable: int
default_selection: List[str]
hint: Optional[str] = None
category_mapping: Dict[str, List[str]] = field(default_factory=dict)
is_category_based: bool = False
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
@@ -149,10 +140,6 @@ class PurchaseServersConfig:
}
if self.hint:
payload["hint"] = self.hint
if self.category_mapping:
payload["category_mapping"] = self.category_mapping
if self.is_category_based:
payload["is_category_based"] = True
return payload
@@ -268,7 +255,6 @@ class PurchaseOptionsContext:
default_period: PurchasePeriodConfig
period_map: Dict[str, PurchasePeriodConfig]
server_uuid_to_id: Dict[str, int]
category_mapping: Dict[str, List[str]]
payload: Dict[str, Any]
@@ -427,7 +413,6 @@ class MiniAppSubscriptionPurchaseService:
user,
texts,
period_days,
available_servers,
server_catalog,
default_connected,
)
@@ -498,8 +483,6 @@ class MiniAppSubscriptionPurchaseService:
"selection": default_selection,
"summary": None,
}
if default_period.servers.category_mapping:
payload["server_category_mapping"] = default_period.servers.category_mapping
return PurchaseOptionsContext(
user=user,
@@ -510,7 +493,6 @@ class MiniAppSubscriptionPurchaseService:
default_period=default_period,
period_map=period_map,
server_uuid_to_id=server_uuid_to_id,
category_mapping=default_period.servers.category_mapping,
payload=payload,
)
@@ -584,123 +566,25 @@ class MiniAppSubscriptionPurchaseService:
user: User,
texts,
period_days: int,
available_servers: List[ServerSquad],
server_catalog: Dict[str, ServerSquad],
default_selection: List[str],
) -> PurchaseServersConfig:
discount_percent = user.get_promo_discount("servers", period_days)
default_selection_set = set(default_selection)
available_server_ids = {server.squad_uuid for server in available_servers}
category_data: Dict[str, Dict[str, Any]] = {}
uncategorized_servers: List[ServerSquad] = []
options: List[PurchaseServerOption] = []
for uuid, server in server_catalog.items():
include_server = uuid in available_server_ids or uuid in default_selection_set
if not include_server:
continue
if server.category_id and getattr(server, "category", None):
key = f"category:{server.category_id}"
data = category_data.setdefault(
key,
{
"category": server.category,
"servers": [],
"min_price": None,
"min_available_price": None,
"has_available": False,
},
)
data["servers"].append(server)
price = server.price_kopeks or 0
if data["min_price"] is None:
data["min_price"] = price
else:
data["min_price"] = min(data["min_price"], price)
if getattr(server, "is_available", True) and not getattr(server, "is_full", False):
data["has_available"] = True
if data["min_available_price"] is None:
data["min_available_price"] = price
else:
data["min_available_price"] = min(data["min_available_price"], price)
else:
uncategorized_servers.append(server)
category_items = list(category_data.items())
category_items.sort(
key=lambda item: (
getattr(item[1]["category"], "sort_order", 0),
getattr(item[1]["category"], "name", item[0]),
)
)
options: List[PurchaseServerOption] = []
category_mapping: Dict[str, List[str]] = {}
for key, data in category_items:
category = data["category"]
base_price = data["min_available_price"]
if base_price is None:
base_price = data["min_price"] or 0
discounted_per_month, discount_value = _apply_percentage_discount(base_price, discount_percent)
option = PurchaseServerOption(
uuid=key,
name=getattr(category, "name", key),
price_per_month=discounted_per_month,
price_label=texts.format_price(discounted_per_month),
original_price_per_month=base_price,
original_price_label=(
texts.format_price(base_price)
if discount_value and base_price != discounted_per_month
else None
),
discount_percent=max(0, discount_percent),
is_available=data["has_available"],
category_id=getattr(category, "id", None),
is_category=True,
)
options.append(option)
category_mapping[key] = [srv.squad_uuid for srv in data["servers"]]
for server in uncategorized_servers:
option = _build_server_option(server, discount_percent, texts)
options.append(option)
if not options:
default_values: List[str] = []
else:
option_lookup = {option.uuid for option in options}
resolved_defaults: List[str] = []
seen: set[str] = set()
for uuid in default_selection:
server = server_catalog.get(uuid)
if server and server.category_id:
key = f"category:{server.category_id}"
if key in option_lookup and key not in seen:
resolved_defaults.append(key)
seen.add(key)
continue
if uuid in option_lookup and uuid not in seen:
resolved_defaults.append(uuid)
seen.add(uuid)
if not resolved_defaults:
resolved_defaults = [options[0].uuid]
default_values = resolved_defaults
default_selection = []
return PurchaseServersConfig(
options=options,
min_selectable=1 if options else 0,
max_selectable=len(options),
default_selection=default_values,
default_selection=default_selection if default_selection else [opt.uuid for opt in options[:1]],
hint=None,
category_mapping=category_mapping,
is_category_based=bool(category_mapping),
)
def _build_devices_config(
@@ -739,40 +623,6 @@ class MiniAppSubscriptionPurchaseService:
hint=None,
)
async def _resolve_selected_servers(
self,
db: AsyncSession,
context: PurchaseOptionsContext,
selected_servers: List[str],
) -> List[str]:
resolved: List[str] = []
promo_group_id = getattr(context.user, "promo_group_id", None)
for identifier in selected_servers:
if identifier.startswith("category:"):
if identifier not in context.category_mapping:
raise PurchaseValidationError("Invalid server category selection", code="invalid_servers")
try:
category_id = int(identifier.split(":", 1)[1])
except (TypeError, ValueError) as error:
raise PurchaseValidationError("Invalid server category selection", code="invalid_servers") from error
squad = await choose_least_loaded_server_in_category(
db,
category_id=category_id,
promo_group_id=promo_group_id,
)
if not squad:
raise PurchaseValidationError(
"No available servers in selected category",
code="no_servers_in_category",
)
resolved.append(squad.squad_uuid)
else:
resolved.append(identifier)
return resolved
def parse_selection(
self,
context: PurchaseOptionsContext,
@@ -872,22 +722,14 @@ class MiniAppSubscriptionPurchaseService:
texts = get_texts(getattr(context.user, "language", None))
months = selection.period.months
resolved_servers = await self._resolve_selected_servers(db, context, selection.servers)
resolved_selection = PurchaseSelection(
period=selection.period,
traffic_value=selection.traffic_value,
servers=resolved_servers,
devices=selection.devices,
)
server_ids = await get_server_ids_by_uuids(db, resolved_selection.servers)
if len(server_ids) != len(resolved_selection.servers):
server_ids = await get_server_ids_by_uuids(db, selection.servers)
if len(server_ids) != len(selection.servers):
raise PurchaseValidationError("Some selected servers are not available", code="invalid_servers")
total_without_promo, details = await self._calculate_base_total(
db,
context.user,
resolved_selection,
selection,
server_ids,
)
@@ -926,7 +768,7 @@ class MiniAppSubscriptionPurchaseService:
raise PurchaseValidationError("Failed to validate pricing", code="calculation_error")
return PurchasePricingResult(
selection=resolved_selection,
selection=selection,
server_ids=server_ids,
server_prices_for_period=list(details.get("servers_individual_prices", [])),
base_original_total=base_original_total,

View File

@@ -113,7 +113,6 @@ class AdminStates(StatesGroup):
editing_server_limit = State()
editing_server_description = State()
editing_server_promo_groups = State()
creating_server_category = State()
creating_server_uuid = State()
creating_server_name = State()

View File

@@ -1,14 +1,11 @@
import pytest
from datetime import datetime, timedelta
from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock
from app.config import settings
from app.database.models import User
from app.database.crud import server_squad as server_squad_crud
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
from app.services.subscription_purchase_service import (
MiniAppSubscriptionPurchaseService,
PurchaseDevicesConfig,
PurchaseOptionsContext,
PurchasePeriodConfig,
@@ -95,7 +92,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
default_period=period_config,
period_map={"days:30": period_config},
server_uuid_to_id={"ru": 1},
category_mapping={},
payload={},
)
@@ -705,110 +701,3 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
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}"
def test_build_servers_config_category_price_ignores_full_squads():
service = MiniAppSubscriptionPurchaseService()
user = MagicMock(spec=User)
user.get_promo_discount = MagicMock(return_value=0)
texts = DummyTexts()
category = MagicMock()
category.id = 7
category.name = "Category"
category.sort_order = 1
full_server = MagicMock()
full_server.squad_uuid = "srv-cheap"
full_server.category_id = category.id
full_server.category = category
full_server.price_kopeks = 10_000
full_server.is_available = True
full_server.is_full = True
available_server = MagicMock()
available_server.squad_uuid = "srv-available"
available_server.category_id = category.id
available_server.category = category
available_server.price_kopeks = 20_000
available_server.is_available = True
available_server.is_full = False
available_servers = [full_server, available_server]
server_catalog = {srv.squad_uuid: srv for srv in available_servers}
config = service._build_servers_config(
user,
texts,
period_days=30,
available_servers=available_servers,
server_catalog=server_catalog,
default_selection=[],
)
category_option = next(option for option in config.options if option.is_category)
assert category_option.price_per_month == available_server.price_kopeks
assert category_option.original_price_per_month == available_server.price_kopeks
assert config.category_mapping[f"category:{category.id}"] == [
full_server.squad_uuid,
available_server.squad_uuid,
]
@pytest.mark.asyncio
async def test_choose_least_loaded_server_in_category_prefers_lowest_load(monkeypatch):
capture: Dict[str, Any] = {}
async def fake_get_available(db, promo_group_id=None, category_id=None, only_with_capacity=False):
capture["only_with_capacity"] = only_with_capacity
high_load = MagicMock()
high_load.max_users = 100
high_load.current_users = 80
high_load.id = 10
low_load = MagicMock()
low_load.max_users = 200
low_load.current_users = 20
low_load.id = 5
return [high_load, low_load]
monkeypatch.setattr(
server_squad_crud,
"get_available_server_squads",
fake_get_available,
)
dummy_db = AsyncMock(spec=AsyncSession)
squad = await server_squad_crud.choose_least_loaded_server_in_category(
dummy_db,
category_id=1,
promo_group_id=2,
)
assert squad.current_users == 20
assert capture["only_with_capacity"] is True
@pytest.mark.asyncio
async def test_choose_least_loaded_server_in_category_returns_none_when_empty(monkeypatch):
async def fake_get_available(db, promo_group_id=None, category_id=None, only_with_capacity=False):
return []
monkeypatch.setattr(
server_squad_crud,
"get_available_server_squads",
fake_get_available,
)
dummy_db = AsyncMock(spec=AsyncSession)
squad = await server_squad_crud.choose_least_loaded_server_in_category(
dummy_db,
category_id=5,
)
assert squad is None