Files
remnawave-bedolaga-telegram…/app/webapi/routes/users.py
PEDZEO 6b69ec750e feat: add cabinet (personal account) backend API
- Add JWT authentication for cabinet users
- Add Telegram WebApp authentication
- Add subscription management endpoints
- Add balance and transactions endpoints
- Add referral system endpoints
- Add tickets support for cabinet
- Add webhooks and websocket for real-time updates
- Add email verification service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 23:20:20 +03:00

481 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)