from __future__ import annotations from typing import Any 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: PromoGroup | None) -> PromoGroupSummary | None: 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: Subscription | None) -> SubscriptionSummary | None: 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, 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: UserStatus | None = Query(default=None, alias='status'), promo_group_id: int | None = Query(default=None), search: str | None = 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: # Check for duplicate telegram_id only if provided (skip for email-only users) if payload.telegram_id is not None: 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) from app.database.crud.subscription import is_active_paid_subscription subscription = await get_subscription_by_user_id(db, user.id) if not subscription: raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription') if is_active_paid_subscription(subscription): logger.info( '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка', user_id=user.id, ) user = await get_user_by_id(db, user.id) return _serialize_user(user) 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)