from __future__ import annotations from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.subscription import ( create_paid_subscription, create_trial_subscription, deactivate_subscription, get_subscription_by_user_id, replace_subscription, ) from app.database.crud.user import ( add_user_balance, create_user, get_user_by_id, get_user_by_referral_code, get_user_by_telegram_id, update_user, ) from app.database.models import PromoGroup, Subscription, User, UserStatus from app.services.subscription_service import SubscriptionService from ..dependencies import get_db_session, require_api_token from ..schemas.users import ( BalanceUpdateRequest, PromoGroupSummary, SubscriptionSummary, UserCreateRequest, UserListResponse, UserResponse, UserSubscriptionCreateRequest, UserUpdateRequest, ) router = APIRouter() def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]: if not group: return None return PromoGroupSummary( id=group.id, name=group.name, server_discount_percent=group.server_discount_percent, traffic_discount_percent=group.traffic_discount_percent, device_discount_percent=group.device_discount_percent, apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True), ) def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]: if not subscription: return None return SubscriptionSummary( id=subscription.id, status=subscription.status, actual_status=subscription.actual_status, is_trial=subscription.is_trial, start_date=subscription.start_date, end_date=subscription.end_date, traffic_limit_gb=subscription.traffic_limit_gb, traffic_used_gb=subscription.traffic_used_gb, device_limit=subscription.device_limit, modem_enabled=getattr(subscription, 'modem_enabled', False) or False, autopay_enabled=subscription.autopay_enabled, autopay_days_before=subscription.autopay_days_before, subscription_url=subscription.subscription_url, subscription_crypto_link=subscription.subscription_crypto_link, connected_squads=list(subscription.connected_squads or []), ) def _serialize_user(user: User) -> UserResponse: subscription = getattr(user, "subscription", None) promo_group = getattr(user, "promo_group", None) return UserResponse( id=user.id, telegram_id=user.telegram_id, username=user.username, first_name=user.first_name, last_name=user.last_name, status=user.status, language=user.language, balance_kopeks=user.balance_kopeks, balance_rubles=round(user.balance_kopeks / 100, 2), referral_code=user.referral_code, referred_by_id=user.referred_by_id, has_had_paid_subscription=user.has_had_paid_subscription, has_made_first_topup=user.has_made_first_topup, created_at=user.created_at, updated_at=user.updated_at, last_activity=user.last_activity, promo_group=_serialize_promo_group(promo_group), subscription=_serialize_subscription(subscription), ) def _apply_search_filter(query, search: str): search_lower = f"%{search.lower()}%" conditions = [ func.lower(User.username).like(search_lower), func.lower(User.first_name).like(search_lower), func.lower(User.last_name).like(search_lower), func.lower(User.referral_code).like(search_lower), ] if search.isdigit(): conditions.append(User.telegram_id == int(search)) conditions.append(User.id == int(search)) return query.where(or_(*conditions)) @router.get("", response_model=UserListResponse) async def list_users( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), status_filter: Optional[UserStatus] = Query(default=None, alias="status"), promo_group_id: Optional[int] = Query(default=None), search: Optional[str] = Query(default=None), ) -> UserListResponse: base_query = ( select(User) .options( selectinload(User.subscription), selectinload(User.promo_group), ) ) if status_filter: base_query = base_query.where(User.status == status_filter.value) if promo_group_id: base_query = base_query.where(User.promo_group_id == promo_group_id) if search: base_query = _apply_search_filter(base_query, search) total_query = base_query.with_only_columns(func.count()).order_by(None) total = await db.scalar(total_query) or 0 result = await db.execute( base_query.order_by(User.created_at.desc()).offset(offset).limit(limit) ) users = result.scalars().unique().all() return UserListResponse( items=[_serialize_user(user) for user in users], total=int(total), limit=limit, offset=offset, ) @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: # First check if the provided ID is a telegram_id user = await get_user_by_telegram_id(db, user_id) if user: return _serialize_user(user) # If not found as telegram_id, check as internal user ID user = await get_user_by_id(db, user_id) if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") return _serialize_user(user) @router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse) async def get_user_by_telegram_id_endpoint( telegram_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: """ Get user by Telegram ID """ user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") return _serialize_user(user) @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user_endpoint( payload: UserCreateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: existing = await get_user_by_telegram_id(db, payload.telegram_id) if existing: raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists") user = await create_user( db, telegram_id=payload.telegram_id, username=payload.username, first_name=payload.first_name, last_name=payload.last_name, language=payload.language, referred_by_id=payload.referred_by_id, ) if payload.promo_group_id and payload.promo_group_id != user.promo_group_id: promo_group = await get_promo_group_by_id(db, payload.promo_group_id) if not promo_group: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found") user = await update_user(db, user, promo_group_id=promo_group.id) user = await get_user_by_id(db, user.id) return _serialize_user(user) @router.patch("/{user_id}", response_model=UserResponse) async def update_user_endpoint( user_id: int, payload: UserUpdateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: # First check if the provided ID is a telegram_id user = await get_user_by_telegram_id(db, user_id) if user: found_user = user else: # If not found as telegram_id, check as internal user ID found_user = await get_user_by_id(db, user_id) if not found_user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") updates: dict[str, Any] = {} if payload.username is not None: updates["username"] = payload.username if payload.first_name is not None: updates["first_name"] = payload.first_name if payload.last_name is not None: updates["last_name"] = payload.last_name if payload.language is not None: updates["language"] = payload.language if payload.has_had_paid_subscription is not None: 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.status is not None: try: status_value = UserStatus(payload.status).value except ValueError as error: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error updates["status"] = status_value if payload.promo_group_id is not None: promo_group = await get_promo_group_by_id(db, payload.promo_group_id) if not promo_group: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found") updates["promo_group_id"] = promo_group.id if payload.referral_code is not None and payload.referral_code != found_user.referral_code: existing_code_owner = await get_user_by_referral_code(db, payload.referral_code) if existing_code_owner and existing_code_owner.id != found_user.id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use") updates["referral_code"] = payload.referral_code if not updates: return _serialize_user(found_user) found_user = await update_user(db, found_user, **updates) # Reload the user to ensure we have the latest data if found_user.telegram_id == user_id: found_user = await get_user_by_telegram_id(db, user_id) else: found_user = await get_user_by_id(db, found_user.id) return _serialize_user(found_user) @router.post("/{user_id}/balance", response_model=UserResponse) async def update_balance( user_id: int, payload: BalanceUpdateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: if payload.amount_kopeks == 0: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero") # First check if the provided ID is a telegram_id user = await get_user_by_telegram_id(db, user_id) if user: found_user = user else: # If not found as telegram_id, check as internal user ID found_user = await get_user_by_id(db, user_id) if not found_user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") success = await add_user_balance( db, found_user, amount_kopeks=payload.amount_kopeks, description=payload.description or "Корректировка через веб-API", create_transaction=payload.create_transaction, ) if not success: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance") # Reload the user to ensure we have the latest data if found_user.telegram_id == user_id: found_user = await get_user_by_telegram_id(db, user_id) else: found_user = await get_user_by_id(db, found_user.id) return _serialize_user(found_user) async def _get_user_by_id_or_telegram_id(db: AsyncSession, user_id: int) -> User: """Helper function to get user by ID or telegram_id""" user = await get_user_by_telegram_id(db, user_id) if user: return user user = await get_user_by_id(db, user_id) if not user: raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") return user @router.post("/{user_id}/subscription", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user_subscription( user_id: int, payload: UserSubscriptionCreateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: """ Создать или заменить подписку для пользователя. Поддерживает создание как триальных, так и платных подписок. """ user = await _get_user_by_id_or_telegram_id(db, user_id) existing = await get_subscription_by_user_id(db, user.id) if existing and not payload.replace_existing: raise HTTPException( status.HTTP_400_BAD_REQUEST, "User already has a subscription. Use replace_existing=true to replace it" ) forced_devices = None if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() if payload.is_trial: trial_device_limit = payload.device_limit if trial_device_limit is None: trial_device_limit = forced_devices duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB if existing: # Сохраняем существующие сквады при замене connected_squads = list(existing.connected_squads or []) if payload.squad_uuid: connected_squads = [payload.squad_uuid] elif payload.connected_squads: connected_squads = payload.connected_squads subscription = await replace_subscription( db, existing, duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=( trial_device_limit if trial_device_limit is not None else settings.TRIAL_DEVICE_LIMIT ), connected_squads=connected_squads, is_trial=True, update_server_counters=True, ) else: subscription = await create_trial_subscription( db, user_id=user.id, duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=trial_device_limit, squad_uuid=payload.squad_uuid, ) else: if payload.duration_days is None: raise HTTPException( status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions" ) device_limit = payload.device_limit if device_limit is None: if forced_devices is not None: device_limit = forced_devices else: device_limit = settings.DEFAULT_DEVICE_LIMIT if existing: subscription = await replace_subscription( db, existing, duration_days=payload.duration_days, traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, device_limit=device_limit, connected_squads=payload.connected_squads or [], is_trial=False, update_server_counters=True, ) else: subscription = await create_paid_subscription( db, user_id=user.id, duration_days=payload.duration_days, traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB, device_limit=device_limit, connected_squads=payload.connected_squads or [], update_server_counters=True, ) # Создаем пользователя в RemnaWave для платных подписок subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) # Перезагружаем пользователя с подпиской user = await get_user_by_id(db, user.id) return _serialize_user(user) @router.delete("/{user_id}/subscription", response_model=UserResponse) async def delete_user_subscription( user_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserResponse: """ Деактивировать подписку пользователя. Подписка не удаляется физически, а помечается как DISABLED. """ user = await _get_user_by_id_or_telegram_id(db, user_id) subscription = await get_subscription_by_user_id(db, user.id) if not subscription: raise HTTPException(status.HTTP_404_NOT_FOUND, "User has no subscription") await deactivate_subscription(db, subscription) # Деактивируем пользователя в RemnaWave, если есть UUID if user.remnawave_uuid: subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) # Перезагружаем пользователя user = await get_user_by_id(db, user.id) return _serialize_user(user)