Merge pull request #2127 from BEDOLAGA-DEV/xs3s5d-bedolaga/add-ability-to-assign-internal-squads

Support user-specific internal squads
This commit is contained in:
Egor
2025-12-08 04:20:37 +03:00
committed by GitHub
10 changed files with 190 additions and 33 deletions

View File

@@ -171,7 +171,8 @@ async def create_user_no_commit(
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
referral_code: str = None,
active_internal_squads: Optional[List[str]] = None,
) -> User:
"""
Создает пользователя без немедленного коммита для пакетной обработки
@@ -197,6 +198,7 @@ async def create_user_no_commit(
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
active_internal_squads=active_internal_squads,
)
db.add(user)
@@ -222,7 +224,8 @@ async def create_user(
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
referral_code: str = None,
active_internal_squads: Optional[List[str]] = None,
) -> User:
if not referral_code:
@@ -248,6 +251,7 @@ async def create_user(
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
active_internal_squads=active_internal_squads,
)
db.add(user)

View File

@@ -585,6 +585,7 @@ class User(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
active_internal_squads = Column(JSON, nullable=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)

View File

@@ -3650,6 +3650,36 @@ async def add_promo_group_priority_column() -> bool:
return False
async def add_user_active_internal_squads_column() -> bool:
"""Добавляет колонку active_internal_squads в таблицу users."""
column_exists = await check_column_exists('users', 'active_internal_squads')
if column_exists:
logger.info("Колонка active_internal_squads уже существует в users")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
column_def = 'JSON'
elif db_type == 'postgresql':
column_def = 'JSONB'
else:
column_def = 'JSON'
await conn.execute(
text(f"ALTER TABLE users ADD COLUMN active_internal_squads {column_def}")
)
logger.info("✅ Добавлена колонка active_internal_squads в users")
return True
except Exception as error:
logger.error(f"Ошибка добавления колонки active_internal_squads: {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")
@@ -3993,6 +4023,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением priority в promo_groups")
logger.info("=== ДОБАВЛЕНИЕ INTERNAL SQUADS ДЛЯ USERS ===")
internal_squads_ready = await add_user_active_internal_squads_column()
if internal_squads_ready:
logger.info("✅ Колонка active_internal_squads в users готова")
else:
logger.warning("⚠️ Проблемы с добавлением active_internal_squads в users")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===")
user_promo_groups_ready = await create_user_promo_groups_table()
if user_promo_groups_ready:

View File

@@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.utils.internal_squads import resolve_user_internal_squads
from app.utils.user_utils import get_effective_referral_commission_percent
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import TrafficLimitStrategy
@@ -3401,7 +3402,7 @@ async def _show_servers_for_user(
user = await get_user_by_id(db, user_id)
current_squads = []
if user and user.subscription:
current_squads = user.subscription.connected_squads or []
current_squads = resolve_user_internal_squads(user, user.subscription)
all_servers, _ = await get_all_server_squads(db, available_only=False)
@@ -3490,18 +3491,19 @@ async def toggle_user_server(
if not server:
await callback.answer("❌ Сервер не найден", show_alert=True)
return
subscription = user.subscription
current_squads = list(subscription.connected_squads or [])
current_squads = resolve_user_internal_squads(user, subscription)
if server.squad_uuid in current_squads:
current_squads.remove(server.squad_uuid)
action_text = "удален"
else:
current_squads.append(server.squad_uuid)
action_text = "добавлен"
subscription.connected_squads = current_squads
user.active_internal_squads = current_squads
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -4590,8 +4592,10 @@ async def admin_buy_subscription_execute(
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
remnawave_service = RemnaWaveService()
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
active_squads = resolve_user_internal_squads(target_user, subscription)
target_user.active_internal_squads = active_squads
if target_user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
@@ -4606,7 +4610,7 @@ async def admin_buy_subscription_execute(
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=active_squads,
)
if hwid_limit is not None:
@@ -4632,7 +4636,7 @@ async def admin_buy_subscription_execute(
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=active_squads,
)
if hwid_limit is not None:

View File

@@ -38,6 +38,7 @@ from app.database.crud.user import (
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.utils.internal_squads import resolve_user_internal_squads
from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
@@ -312,7 +313,9 @@ class MonitoringService:
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=resolve_user_internal_squads(
user, subscription
),
)
if hwid_limit is not None:

View File

@@ -42,6 +42,7 @@ from app.database.models import (
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.utils.internal_squads import resolve_user_internal_squads
from app.utils.timezone import get_local_timezone
logger = logging.getLogger(__name__)
@@ -182,6 +183,70 @@ class RemnaWaveService:
self._config_error or "RemnaWave API не настроен"
)
async def resolve_internal_squad_uuids(self, identifiers: List[str]) -> List[str]:
"""Конвертирует названия/UUID сквадов в UUID через панель RemnaWave."""
if not identifiers:
return []
cleaned: List[str] = []
seen = set()
for ident in identifiers:
if not ident:
continue
value = str(ident).strip()
if not value or value in seen:
continue
seen.add(value)
cleaned.append(value)
if not cleaned:
return []
try:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
except Exception as error:
logger.error("Не удалось получить список Internal Squads: %s", error)
return []
name_to_uuid = {squad.name.lower(): squad.uuid for squad in squads}
uuid_set = {squad.uuid for squad in squads}
resolved: List[str] = []
for value in cleaned:
if value in uuid_set:
resolved.append(value)
continue
mapped = name_to_uuid.get(value.lower())
if mapped:
resolved.append(mapped)
else:
logger.warning("⚠️ Неизвестный Internal Squad: %s", value)
return resolved
async def normalize_active_internal_squads(self, active_squads: List[Any]) -> List[str]:
"""Приводит входные данные о сквадах (имя/UUID/словарь) к списку UUID."""
identifiers: List[str] = []
for squad in active_squads or []:
identifier = None
if isinstance(squad, dict):
identifier = squad.get('uuid') or squad.get('name')
else:
identifier = squad
if identifier:
identifiers.append(str(identifier))
return await self.resolve_internal_squad_uuids(identifiers)
def _ensure_user_remnawave_uuid(
self,
user: "User",
@@ -1017,6 +1082,7 @@ class RemnaWaveService:
uuid=subscription.user.remnawave_uuid,
active_internal_squads=new_squads,
)
subscription.user.active_internal_squads = new_squads
panel_updated += 1
except Exception as error:
panel_failed += 1
@@ -1506,14 +1572,9 @@ class RemnaWaveService:
traffic_used_gb = used_traffic_bytes / (1024**3)
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
for squad in active_squads:
if isinstance(squad, dict) and 'uuid' in squad:
squad_uuids.append(squad['uuid'])
elif isinstance(squad, str):
squad_uuids.append(squad)
squad_uuids = await self.normalize_active_internal_squads(active_squads)
user.active_internal_squads = squad_uuids
subscription_data = {
'user_id': user.id,
'status': status.value,
@@ -1647,16 +1708,11 @@ class RemnaWaveService:
)
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
subscription.subscription_crypto_link = panel_crypto_link
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
for squad in active_squads:
if isinstance(squad, dict) and 'uuid' in squad:
squad_uuids.append(squad['uuid'])
elif isinstance(squad, str):
squad_uuids.append(squad)
squad_uuids = await self.normalize_active_internal_squads(active_squads)
user.active_internal_squads = squad_uuids
current_squads = set(subscription.connected_squads or [])
new_squads = set(squad_uuids)
@@ -1694,6 +1750,7 @@ class RemnaWaveService:
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
active_squads = resolve_user_internal_squads(user, subscription)
expire_at = self._safe_expire_at_for_panel(subscription.end_date)
status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED
@@ -1716,7 +1773,7 @@ class RemnaWaveService:
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=active_squads,
)
if hwid_limit is not None:
@@ -1730,7 +1787,7 @@ class RemnaWaveService:
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=create_kwargs['description'],
active_internal_squads=subscription.connected_squads,
active_internal_squads=active_squads,
)
if hwid_limit is not None:

View File

@@ -11,6 +11,7 @@ from app.external.remnawave_api import (
TrafficLimitStrategy, RemnaWaveAPIError
)
from app.database.crud.user import get_user_by_id
from app.utils.internal_squads import resolve_user_internal_squads
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
@@ -207,7 +208,9 @@ class SubscriptionService:
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=resolve_user_internal_squads(
user, subscription
),
)
if user_tag is not None:
@@ -245,7 +248,9 @@ class SubscriptionService:
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=resolve_user_internal_squads(
user, subscription
),
)
if user_tag is not None:
@@ -328,7 +333,9 @@ class SubscriptionService:
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
active_internal_squads=resolve_user_internal_squads(
user, subscription
),
)
if user_tag is not None:

View File

@@ -0,0 +1,21 @@
from typing import List, Optional
from app.database.models import Subscription, User
def resolve_user_internal_squads(
user: Optional[User], subscription: Optional[Subscription]
) -> List[str]:
"""Возвращает список Internal Squads для пользователя.
Приоритет у персональных сквадов пользователя. Если они не заданы,
используется список сквадов из подписки.
"""
if user and getattr(user, "active_internal_squads", None) is not None:
return list(user.active_internal_squads or [])
if subscription:
return list(subscription.connected_squads or [])
return []

View File

@@ -17,6 +17,7 @@ from app.database.crud.user import (
update_user,
)
from app.database.models import PromoGroup, Subscription, User, UserStatus
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_db_session, require_api_token
from ..schemas.users import (
@@ -30,6 +31,7 @@ from ..schemas.users import (
)
router = APIRouter()
remnawave_service = RemnaWaveService()
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
@@ -90,6 +92,7 @@ def _serialize_user(user: User) -> UserResponse:
last_activity=user.last_activity,
promo_group=_serialize_promo_group(promo_group),
subscription=_serialize_subscription(subscription),
active_internal_squads=list(getattr(user, "active_internal_squads", []) or []),
)
@@ -197,6 +200,12 @@ async def create_user_endpoint(
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
active_squads = None
if payload.active_internal_squads is not None:
active_squads = await remnawave_service.resolve_internal_squad_uuids(
payload.active_internal_squads
)
user = await create_user(
db,
telegram_id=payload.telegram_id,
@@ -205,6 +214,7 @@ async def create_user_endpoint(
last_name=payload.last_name,
language=payload.language,
referred_by_id=payload.referred_by_id,
active_internal_squads=active_squads,
)
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
@@ -249,6 +259,10 @@ async def update_user_endpoint(
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
if payload.has_made_first_topup is not None:
updates["has_made_first_topup"] = payload.has_made_first_topup
if payload.active_internal_squads is not None:
updates["active_internal_squads"] = await remnawave_service.resolve_internal_squad_uuids(
payload.active_internal_squads
)
if payload.status is not None:
try:

View File

@@ -51,6 +51,7 @@ class UserResponse(BaseModel):
last_activity: Optional[datetime] = None
promo_group: Optional[PromoGroupSummary] = None
subscription: Optional[SubscriptionSummary] = None
active_internal_squads: List[str] = Field(default_factory=list)
class UserListResponse(BaseModel):
@@ -68,6 +69,10 @@ class UserCreateRequest(BaseModel):
language: str = "ru"
referred_by_id: Optional[int] = None
promo_group_id: Optional[int] = None
active_internal_squads: Optional[List[str]] = Field(
default=None,
description="Названия или UUID Internal Squads, которые нужно назначить пользователю"
)
class UserUpdateRequest(BaseModel):
@@ -80,6 +85,10 @@ class UserUpdateRequest(BaseModel):
referral_code: Optional[str] = None
has_had_paid_subscription: Optional[bool] = None
has_made_first_topup: Optional[bool] = None
active_internal_squads: Optional[List[str]] = Field(
default=None,
description="Названия или UUID Internal Squads, которые нужно назначить пользователю"
)
class BalanceUpdateRequest(BaseModel):