diff --git a/app/cabinet/__init__.py b/app/cabinet/__init__.py new file mode 100644 index 00000000..e3de26fc --- /dev/null +++ b/app/cabinet/__init__.py @@ -0,0 +1,10 @@ +""" +Cabinet module - Personal Account for VPN Bot users. + +This module provides: +- JWT-based authentication (Telegram + Email) +- Subscription management +- Balance & payments +- Referral program +- Support tickets +""" diff --git a/app/cabinet/auth/__init__.py b/app/cabinet/auth/__init__.py new file mode 100644 index 00000000..e14023bc --- /dev/null +++ b/app/cabinet/auth/__init__.py @@ -0,0 +1,21 @@ +"""Cabinet authentication module.""" + +from .password_utils import hash_password, verify_password +from .jwt_handler import ( + create_access_token, + create_refresh_token, + decode_token, + get_token_payload, +) +from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data + +__all__ = [ + "hash_password", + "verify_password", + "create_access_token", + "create_refresh_token", + "decode_token", + "get_token_payload", + "validate_telegram_login_widget", + "validate_telegram_init_data", +] diff --git a/app/cabinet/auth/email_verification.py b/app/cabinet/auth/email_verification.py new file mode 100644 index 00000000..210a4cdf --- /dev/null +++ b/app/cabinet/auth/email_verification.py @@ -0,0 +1,64 @@ +"""Email verification token generation and validation.""" + +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from app.config import settings + + +def generate_verification_token() -> str: + """ + Generate a secure random verification token. + + Returns: + 32-character hex token string + """ + return secrets.token_hex(32) + + +def generate_password_reset_token() -> str: + """ + Generate a secure random password reset token. + + Returns: + 32-character hex token string + """ + return secrets.token_hex(32) + + +def get_verification_expires_at() -> datetime: + """ + Get the expiration datetime for a verification token. + + Returns: + Datetime when the verification token expires + """ + hours = settings.get_cabinet_email_verification_expire_hours() + return datetime.utcnow() + timedelta(hours=hours) + + +def get_password_reset_expires_at() -> datetime: + """ + Get the expiration datetime for a password reset token. + + Returns: + Datetime when the password reset token expires + """ + hours = settings.get_cabinet_password_reset_expire_hours() + return datetime.utcnow() + timedelta(hours=hours) + + +def is_token_expired(expires_at: Optional[datetime]) -> bool: + """ + Check if a token has expired. + + Args: + expires_at: Token expiration datetime + + Returns: + True if expired or no expiration set, False otherwise + """ + if expires_at is None: + return True + return datetime.utcnow() > expires_at diff --git a/app/cabinet/auth/jwt_handler.py b/app/cabinet/auth/jwt_handler.py new file mode 100644 index 00000000..dcd9ffa6 --- /dev/null +++ b/app/cabinet/auth/jwt_handler.py @@ -0,0 +1,106 @@ +"""JWT token handling for cabinet authentication.""" + +import jwt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from app.config import settings + +JWT_ALGORITHM = "HS256" + + +def create_access_token(user_id: int, telegram_id: int) -> str: + """ + Create a short-lived access token. + + Args: + user_id: Database user ID + telegram_id: Telegram user ID + + Returns: + Encoded JWT access token + """ + expire_minutes = settings.get_cabinet_access_token_expire_minutes() + expires = datetime.utcnow() + timedelta(minutes=expire_minutes) + + payload = { + "sub": str(user_id), + "telegram_id": telegram_id, + "type": "access", + "exp": expires, + "iat": datetime.utcnow(), + } + + secret = settings.get_cabinet_jwt_secret() + return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) + + +def create_refresh_token(user_id: int) -> str: + """ + Create a long-lived refresh token. + + Args: + user_id: Database user ID + + Returns: + Encoded JWT refresh token + """ + expire_days = settings.get_cabinet_refresh_token_expire_days() + expires = datetime.utcnow() + timedelta(days=expire_days) + + payload = { + "sub": str(user_id), + "type": "refresh", + "exp": expires, + "iat": datetime.utcnow(), + } + + secret = settings.get_cabinet_jwt_secret() + return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) + + +def decode_token(token: str) -> Optional[Dict[str, Any]]: + """ + Decode and validate a JWT token. + + Args: + token: JWT token string + + Returns: + Decoded payload dict or None if invalid/expired + """ + try: + secret = settings.get_cabinet_jwt_secret() + return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM]) + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]: + """ + Decode token and verify its type. + + Args: + token: JWT token string + expected_type: Expected token type ("access" or "refresh") + + Returns: + Decoded payload dict or None if invalid/expired/wrong type + """ + payload = decode_token(token) + + if not payload: + return None + + if payload.get("type") != expected_type: + return None + + return payload + + +def get_refresh_token_expires_at() -> datetime: + """Get the expiration datetime for a new refresh token.""" + expire_days = settings.get_cabinet_refresh_token_expire_days() + return datetime.utcnow() + timedelta(days=expire_days) diff --git a/app/cabinet/auth/password_utils.py b/app/cabinet/auth/password_utils.py new file mode 100644 index 00000000..a9bf282e --- /dev/null +++ b/app/cabinet/auth/password_utils.py @@ -0,0 +1,40 @@ +"""Password hashing utilities using bcrypt.""" + +import bcrypt + +BCRYPT_ROUNDS = 12 + + +def hash_password(password: str) -> str: + """ + Hash a password using bcrypt. + + Args: + password: Plain text password + + Returns: + Hashed password string + """ + password_bytes = password.encode("utf-8") + salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS) + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + """ + Verify a password against its hash. + + Args: + password: Plain text password to verify + password_hash: Previously hashed password + + Returns: + True if password matches, False otherwise + """ + try: + password_bytes = password.encode("utf-8") + hash_bytes = password_hash.encode("utf-8") + return bcrypt.checkpw(password_bytes, hash_bytes) + except (ValueError, TypeError): + return False diff --git a/app/cabinet/auth/telegram_auth.py b/app/cabinet/auth/telegram_auth.py new file mode 100644 index 00000000..13dea238 --- /dev/null +++ b/app/cabinet/auth/telegram_auth.py @@ -0,0 +1,137 @@ +"""Telegram authentication validation for cabinet.""" + +import hashlib +import hmac +import json +from datetime import datetime +from typing import Dict, Any, Optional +from urllib.parse import parse_qsl, unquote + +from app.config import settings + + +def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = 86400) -> bool: + """ + Validate Telegram Login Widget data. + + https://core.telegram.org/widgets/login#checking-authorization + + Args: + data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.) + max_age_seconds: Maximum allowed age of auth_date (default 24 hours) + + Returns: + True if data is valid, False otherwise + """ + auth_data = data.copy() + check_hash = auth_data.pop("hash", None) + + if not check_hash: + return False + + # Check auth_date is not too old + auth_date = auth_data.get("auth_date") + if auth_date: + try: + auth_time = datetime.fromtimestamp(int(auth_date)) + age = (datetime.utcnow() - auth_time).total_seconds() + if age > max_age_seconds: + return False + except (ValueError, TypeError, OSError): + return False + + # Build data-check-string (sorted key=value pairs, newline-separated) + data_check_arr = [f"{k}={v}" for k, v in sorted(auth_data.items()) if v is not None] + data_check_string = "\n".join(data_check_arr) + + # Create secret key from bot token using SHA256 + bot_token = settings.BOT_TOKEN + secret_key = hashlib.sha256(bot_token.encode()).digest() + + # Calculate expected hash + calculated_hash = hmac.new( + secret_key, + data_check_string.encode(), + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(calculated_hash, check_hash) + + +def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> Optional[Dict[str, Any]]: + """ + Validate Telegram WebApp initData. + + https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + + Args: + init_data: Raw initData string from Telegram WebApp + max_age_seconds: Maximum allowed age of auth_date (default 24 hours) + + Returns: + Parsed user data dict if valid, None otherwise + """ + try: + # Parse the init_data string + parsed = dict(parse_qsl(init_data, keep_blank_values=True)) + + received_hash = parsed.pop("hash", None) + if not received_hash: + return None + + # Check auth_date is not too old + auth_date = parsed.get("auth_date") + if auth_date: + try: + auth_time = datetime.fromtimestamp(int(auth_date)) + age = (datetime.utcnow() - auth_time).total_seconds() + if age > max_age_seconds: + return None + except (ValueError, TypeError, OSError): + return None + + # Build data-check-string + data_check_arr = [f"{k}={v}" for k, v in sorted(parsed.items())] + data_check_string = "\n".join(data_check_arr) + + # Create secret key: HMAC_SHA256(bot_token, "WebAppData") + bot_token = settings.BOT_TOKEN + secret_key = hmac.new( + b"WebAppData", + bot_token.encode(), + hashlib.sha256 + ).digest() + + # Calculate expected hash + calculated_hash = hmac.new( + secret_key, + data_check_string.encode(), + hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(calculated_hash, received_hash): + return None + + # Parse user data from the validated data + user_data_str = parsed.get("user") + if user_data_str: + user_data = json.loads(unquote(user_data_str)) + return user_data + + return parsed + + except (ValueError, TypeError, json.JSONDecodeError): + return None + + +def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]: + """ + Extract and validate user info from Telegram WebApp initData. + + Args: + init_data: Raw initData string from Telegram WebApp + + Returns: + User data dict with id, first_name, last_name, username, etc. or None if invalid + """ + return validate_telegram_init_data(init_data) diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py new file mode 100644 index 00000000..bd76b302 --- /dev/null +++ b/app/cabinet/dependencies.py @@ -0,0 +1,140 @@ +"""FastAPI dependencies for cabinet module.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional + +from app.database.database import AsyncSessionLocal +from app.database.models import User +from app.database.crud.user import get_user_by_id +from app.config import settings +from .auth.jwt_handler import get_token_payload + +security = HTTPBearer(auto_error=False) + + +async def get_cabinet_db() -> AsyncSession: + """Get database session for cabinet operations.""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def get_current_cabinet_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_cabinet_db), +) -> User: + """ + Get current authenticated cabinet user from JWT token. + + Args: + credentials: HTTP Bearer credentials + db: Database session + + Returns: + Authenticated User object + + Raises: + HTTPException: If token is invalid, expired, or user not found + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + payload = get_token_payload(token, expected_type="access") + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + user_id = int(payload.get("sub")) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = await get_user_by_id(db, user_id) + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is not active", + ) + + return user + + +async def get_optional_cabinet_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_cabinet_db), +) -> Optional[User]: + """ + Optionally get current authenticated cabinet user. + + Returns None if no valid token is provided instead of raising an exception. + """ + if not credentials: + return None + + token = credentials.credentials + payload = get_token_payload(token, expected_type="access") + + if not payload: + return None + + try: + user_id = int(payload.get("sub")) + except (TypeError, ValueError): + return None + + user = await get_user_by_id(db, user_id) + + if not user or user.status != "active": + return None + + return user + + +async def get_current_admin_user( + user: User = Depends(get_current_cabinet_user), +) -> User: + """ + Get current authenticated admin user. + + Checks if the user's telegram_id is in ADMIN_IDS from settings. + + Args: + user: Authenticated User object + + Returns: + Authenticated admin User object + + Raises: + HTTPException: If user is not an admin + """ + if not settings.is_admin(user.telegram_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + + return user diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py new file mode 100644 index 00000000..612976c1 --- /dev/null +++ b/app/cabinet/routes/__init__.py @@ -0,0 +1,43 @@ +"""Cabinet API routes.""" + +from fastapi import APIRouter + +from .auth import router as auth_router +from .subscription import router as subscription_router +from .balance import router as balance_router +from .referral import router as referral_router +from .tickets import router as tickets_router +from .admin_tickets import router as admin_tickets_router +from .admin_settings import router as admin_settings_router +from .admin_apps import router as admin_apps_router +from .promocode import router as promocode_router +from .contests import router as contests_router +from .polls import router as polls_router +from .promo import router as promo_router +from .notifications import router as notifications_router +from .info import router as info_router +from .branding import router as branding_router + +# Main cabinet router +router = APIRouter(prefix="/cabinet", tags=["Cabinet"]) + +# Include all sub-routers +router.include_router(auth_router) +router.include_router(subscription_router) +router.include_router(balance_router) +router.include_router(referral_router) +router.include_router(tickets_router) +router.include_router(promocode_router) +router.include_router(contests_router) +router.include_router(polls_router) +router.include_router(promo_router) +router.include_router(notifications_router) +router.include_router(info_router) +router.include_router(branding_router) + +# Admin routes +router.include_router(admin_tickets_router) +router.include_router(admin_settings_router) +router.include_router(admin_apps_router) + +__all__ = ["router"] diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py new file mode 100644 index 00000000..61eca0c4 --- /dev/null +++ b/app/cabinet/routes/admin_apps.py @@ -0,0 +1,421 @@ +"""Admin routes for managing VPN applications in app-config.json.""" + +import json +import logging +from typing import List, Optional, Dict, Any +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_admin_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/apps", tags=["Cabinet Admin Apps"]) + + +# ============ Schemas ============ + +class LocalizedText(BaseModel): + """Localized text for multiple languages.""" + en: str = "" + ru: str = "" + zh: Optional[str] = "" + fa: Optional[str] = "" + + +class AppButton(BaseModel): + """Button with link and localized text.""" + buttonLink: str + buttonText: LocalizedText + + +class AppStep(BaseModel): + """Step with description and optional buttons/title.""" + description: LocalizedText + buttons: Optional[List[AppButton]] = None + title: Optional[LocalizedText] = None + + +class AppDefinition(BaseModel): + """VPN application definition.""" + id: str + name: str + isFeatured: bool = False + urlScheme: str + isNeedBase64Encoding: Optional[bool] = None + installationStep: AppStep + addSubscriptionStep: AppStep + connectAndUseStep: AppStep + additionalBeforeAddSubscriptionStep: Optional[AppStep] = None + additionalAfterAddSubscriptionStep: Optional[AppStep] = None + + +class PlatformApps(BaseModel): + """Apps for a specific platform.""" + platform: str + apps: List[AppDefinition] + + +class AppConfigBranding(BaseModel): + """Branding configuration.""" + name: str + logoUrl: str + supportUrl: str + + +class AppConfigConfig(BaseModel): + """Top-level config section.""" + additionalLocales: List[str] + branding: AppConfigBranding + + +class AppConfigResponse(BaseModel): + """Full app config response.""" + config: AppConfigConfig + platforms: Dict[str, List[AppDefinition]] + + +class CreateAppRequest(BaseModel): + """Request to create a new app.""" + platform: str + app: AppDefinition + + +class UpdateAppRequest(BaseModel): + """Request to update an app.""" + app: AppDefinition + + +class ReorderAppsRequest(BaseModel): + """Request to reorder apps in a platform.""" + app_ids: List[str] + + +class UpdateBrandingRequest(BaseModel): + """Request to update branding.""" + branding: AppConfigBranding + + +# ============ Helpers ============ + +def _get_config_path() -> Path: + """Get path to app-config.json.""" + return Path(settings.get_app_config_path()) + + +def _load_config() -> dict: + """Load app config from file.""" + config_path = _get_config_path() + if not config_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"App config file not found: {config_path}", + ) + + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to parse app config: {e}", + ) + + +def _save_config(config: dict) -> None: + """Save app config to file.""" + config_path = _get_config_path() + + try: + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save app config: {e}", + ) + + +VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"] + + +# ============ Routes ============ + +@router.get("", response_model=AppConfigResponse) +async def get_app_config( + admin: User = Depends(get_current_admin_user), +): + """Get full app configuration.""" + config = _load_config() + return config + + +@router.get("/platforms", response_model=List[str]) +async def get_platforms( + admin: User = Depends(get_current_admin_user), +): + """Get list of available platforms.""" + return VALID_PLATFORMS + + +@router.get("/platforms/{platform}", response_model=List[AppDefinition]) +async def get_platform_apps( + platform: str, + admin: User = Depends(get_current_admin_user), +): + """Get apps for a specific platform.""" + if platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + return platforms.get(platform, []) + + +@router.post("/platforms/{platform}", response_model=AppDefinition) +async def create_app( + platform: str, + request: CreateAppRequest, + admin: User = Depends(get_current_admin_user), +): + """Create a new app for a platform.""" + if platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform: {platform}", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + + if platform not in platforms: + platforms[platform] = [] + + # Check if app with same ID already exists + existing_ids = [app.get("id") for app in platforms[platform]] + if request.app.id in existing_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"App with ID '{request.app.id}' already exists in {platform}", + ) + + # Add new app + app_dict = request.app.model_dump(exclude_none=True) + platforms[platform].append(app_dict) + config["platforms"] = platforms + + _save_config(config) + logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'") + + return request.app + + +@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition) +async def update_app( + platform: str, + app_id: str, + request: UpdateAppRequest, + admin: User = Depends(get_current_admin_user), +): + """Update an existing app.""" + if platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform: {platform}", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + apps = platforms.get(platform, []) + + # Find and update app + app_index = None + for i, app in enumerate(apps): + if app.get("id") == app_id: + app_index = i + break + + if app_index is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"App '{app_id}' not found in platform '{platform}'", + ) + + # Update app + app_dict = request.app.model_dump(exclude_none=True) + apps[app_index] = app_dict + platforms[platform] = apps + config["platforms"] = platforms + + _save_config(config) + logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'") + + return request.app + + +@router.delete("/platforms/{platform}/{app_id}") +async def delete_app( + platform: str, + app_id: str, + admin: User = Depends(get_current_admin_user), +): + """Delete an app from a platform.""" + if platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform: {platform}", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + apps = platforms.get(platform, []) + + # Find and remove app + original_length = len(apps) + apps = [app for app in apps if app.get("id") != app_id] + + if len(apps) == original_length: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"App '{app_id}' not found in platform '{platform}'", + ) + + platforms[platform] = apps + config["platforms"] = platforms + + _save_config(config) + logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'") + + return {"status": "deleted", "app_id": app_id} + + +@router.post("/platforms/{platform}/reorder") +async def reorder_apps( + platform: str, + request: ReorderAppsRequest, + admin: User = Depends(get_current_admin_user), +): + """Reorder apps in a platform.""" + if platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform: {platform}", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + apps = platforms.get(platform, []) + + # Create a map of apps by ID + apps_map = {app.get("id"): app for app in apps} + + # Verify all IDs exist + for app_id in request.app_ids: + if app_id not in apps_map: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"App '{app_id}' not found in platform '{platform}'", + ) + + # Reorder apps + reordered_apps = [apps_map[app_id] for app_id in request.app_ids] + + # Add any apps that weren't in the reorder list (shouldn't happen but just in case) + for app in apps: + if app.get("id") not in request.app_ids: + reordered_apps.append(app) + + platforms[platform] = reordered_apps + config["platforms"] = platforms + + _save_config(config) + logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'") + + return {"status": "reordered", "order": request.app_ids} + + +@router.put("/branding", response_model=AppConfigBranding) +async def update_branding( + request: UpdateBrandingRequest, + admin: User = Depends(get_current_admin_user), +): + """Update branding configuration.""" + config = _load_config() + + if "config" not in config: + config["config"] = {} + + config["config"]["branding"] = request.branding.model_dump() + + _save_config(config) + logger.info(f"Admin {admin.id} updated branding") + + return request.branding + + +@router.get("/branding", response_model=AppConfigBranding) +async def get_branding( + admin: User = Depends(get_current_admin_user), +): + """Get branding configuration.""" + config = _load_config() + branding = config.get("config", {}).get("branding", {}) + return branding + + +@router.post("/platforms/{platform}/copy/{app_id}") +async def copy_app_to_platform( + platform: str, + app_id: str, + target_platform: str, + admin: User = Depends(get_current_admin_user), +): + """Copy an app from one platform to another.""" + if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid platform(s)", + ) + + config = _load_config() + platforms = config.get("platforms", {}) + source_apps = platforms.get(platform, []) + + # Find source app + source_app = None + for app in source_apps: + if app.get("id") == app_id: + source_app = app.copy() + break + + if not source_app: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"App '{app_id}' not found in platform '{platform}'", + ) + + # Generate new ID for copied app + import time + new_id = f"{app_id}-copy-{int(time.time())}" + source_app["id"] = new_id + + # Add to target platform + if target_platform not in platforms: + platforms[target_platform] = [] + + platforms[target_platform].append(source_app) + config["platforms"] = platforms + + _save_config(config) + logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'") + + return {"status": "copied", "new_id": new_id, "target_platform": target_platform} diff --git a/app/cabinet/routes/admin_settings.py b/app/cabinet/routes/admin_settings.py new file mode 100644 index 00000000..696a1274 --- /dev/null +++ b/app/cabinet/routes/admin_settings.py @@ -0,0 +1,264 @@ +"""Admin settings routes for cabinet - system configuration management.""" + +import logging +from typing import Any, Optional, List + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.services.system_settings_service import ( + ReadOnlySettingError, + bot_configuration_service, +) + +from ..dependencies import get_cabinet_db, get_current_admin_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"]) + + +# ============ Schemas ============ + +class SettingCategoryRef(BaseModel): + """Reference to category.""" + key: str + label: str + + +class SettingCategorySummary(BaseModel): + """Category summary.""" + key: str + label: str + description: str = "" + items: int + + +class SettingChoice(BaseModel): + """Choice option for setting.""" + value: Any + label: str + description: Optional[str] = None + + +class SettingHint(BaseModel): + """Setting hints and guidance.""" + description: str = "" + format: str = "" + example: str = "" + warning: str = "" + + +class SettingDefinition(BaseModel): + """Full setting definition with current state.""" + key: str + name: str + category: SettingCategoryRef + type: str + is_optional: bool + current: Any = Field(default=None) + original: Any = Field(default=None) + has_override: bool + read_only: bool = Field(default=False) + choices: List[SettingChoice] = Field(default_factory=list) + hint: Optional[SettingHint] = None + + +class SettingUpdateRequest(BaseModel): + """Request to update setting value.""" + value: Any + + +# ============ Helper Functions ============ + +def _coerce_value(key: str, value: Any) -> Any: + """Convert and validate value for a setting.""" + definition = bot_configuration_service.get_definition(key) + + if value is None: + if definition.is_optional: + return None + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required") + + python_type = definition.python_type + + try: + if python_type is bool: + if isinstance(value, bool): + normalized = value + elif isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on", "да"}: + normalized = True + elif lowered in {"false", "0", "no", "off", "нет"}: + normalized = False + else: + raise ValueError("invalid bool") + else: + raise ValueError("invalid bool") + + elif python_type is int: + normalized = int(value) + elif python_type is float: + normalized = float(value) + else: + normalized = str(value) + except ValueError: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None + + choices = bot_configuration_service.get_choice_options(key) + if choices: + allowed_values = {option.value for option in choices} + if normalized not in allowed_values: + readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Value must be one of: {readable}", + ) + + return normalized + + +def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition: + """Serialize setting definition to response model.""" + current = bot_configuration_service.get_current_value(definition.key) + original = bot_configuration_service.get_original_value(definition.key) + has_override = bot_configuration_service.has_override(definition.key) + + choices: List[SettingChoice] = [] + if include_choices: + choices = [ + SettingChoice( + value=option.value, + label=option.label, + description=option.description, + ) + for option in bot_configuration_service.get_choice_options(definition.key) + ] + + # Get setting hints + guidance = bot_configuration_service.get_setting_guidance(definition.key) + hint = SettingHint( + description=guidance.get("description", ""), + format=guidance.get("format", ""), + example=guidance.get("example", ""), + warning=guidance.get("warning", ""), + ) + + return SettingDefinition( + key=definition.key, + name=definition.display_name, + category=SettingCategoryRef( + key=definition.category_key, + label=definition.category_label, + ), + type=definition.type_label, + is_optional=definition.is_optional, + current=current, + original=original, + has_override=has_override, + read_only=bot_configuration_service.is_read_only(definition.key), + choices=choices, + hint=hint, + ) + + +# ============ Routes ============ + +@router.get("/categories", response_model=List[SettingCategorySummary]) +async def list_categories( + admin: User = Depends(get_current_admin_user), +): + """Get list of setting categories.""" + categories = bot_configuration_service.get_categories() + return [ + SettingCategorySummary( + key=key, + label=label, + description=bot_configuration_service.get_category_description(key), + items=count, + ) + for key, label, count in categories + ] + + +@router.get("", response_model=List[SettingDefinition]) +async def list_settings( + admin: User = Depends(get_current_admin_user), + category: Optional[str] = Query(default=None, alias="category_key"), +): + """Get list of all settings or settings for a specific category.""" + items: List[SettingDefinition] = [] + + if category: + definitions = bot_configuration_service.get_settings_for_category(category) + items.extend(_serialize_definition(defn) for defn in definitions) + return items + + for category_key, _, _ in bot_configuration_service.get_categories(): + definitions = bot_configuration_service.get_settings_for_category(category_key) + items.extend(_serialize_definition(defn) for defn in definitions) + + return items + + +@router.get("/{key}", response_model=SettingDefinition) +async def get_setting( + key: str, + admin: User = Depends(get_current_admin_user), +): + """Get a specific setting by key.""" + try: + definition = bot_configuration_service.get_definition(key) + except KeyError as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + + return _serialize_definition(definition) + + +@router.put("/{key}", response_model=SettingDefinition) +async def update_setting( + key: str, + payload: SettingUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update a setting value.""" + try: + definition = bot_configuration_service.get_definition(key) + except KeyError as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + + value = _coerce_value(key, payload.value) + try: + await bot_configuration_service.set_value(db, key, value) + except ReadOnlySettingError as error: + raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await db.commit() + + logger.info(f"Admin {admin.telegram_id} updated setting {key} to {value}") + return _serialize_definition(definition) + + +@router.delete("/{key}", response_model=SettingDefinition) +async def reset_setting( + key: str, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Reset a setting to its default value.""" + try: + definition = bot_configuration_service.get_definition(key) + except KeyError as error: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + + try: + await bot_configuration_service.reset_value(db, key) + except ReadOnlySettingError as error: + raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await db.commit() + + logger.info(f"Admin {admin.telegram_id} reset setting {key}") + return _serialize_definition(definition) diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py new file mode 100644 index 00000000..9f3acc87 --- /dev/null +++ b/app/cabinet/routes/admin_tickets.py @@ -0,0 +1,450 @@ +"""Admin tickets routes for cabinet.""" + +import logging +import math +from datetime import datetime +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc +from sqlalchemy.orm import selectinload +from pydantic import BaseModel, Field + +from app.database.models import User, Ticket, TicketMessage +from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_admin_user +from ..schemas.tickets import TicketMessageResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"]) + + +# Admin-specific schemas +class AdminTicketUserInfo(BaseModel): + """User info for admin view.""" + id: int + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + + class Config: + from_attributes = True + + +class AdminTicketResponse(BaseModel): + """Ticket data for admin.""" + id: int + title: str + status: str + priority: str + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] = None + messages_count: int = 0 + user: Optional[AdminTicketUserInfo] = None + last_message: Optional[TicketMessageResponse] = None + + class Config: + from_attributes = True + + +class AdminTicketDetailResponse(BaseModel): + """Ticket with all messages for admin.""" + id: int + title: str + status: str + priority: str + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] = None + is_reply_blocked: bool = False + user: Optional[AdminTicketUserInfo] = None + messages: List[TicketMessageResponse] = [] + + class Config: + from_attributes = True + + +class AdminTicketListResponse(BaseModel): + """Paginated ticket list for admin.""" + items: List[AdminTicketResponse] + total: int + page: int + per_page: int + pages: int + + +class AdminReplyRequest(BaseModel): + """Admin reply to ticket.""" + message: str = Field(..., min_length=1, max_length=4000, description="Reply message") + + +class AdminStatusUpdateRequest(BaseModel): + """Update ticket status.""" + status: str = Field(..., description="New status: open, answered, pending, closed") + + +class AdminPriorityUpdateRequest(BaseModel): + """Update ticket priority.""" + priority: str = Field(..., description="New priority: low, normal, high, urgent") + + +class AdminStatsResponse(BaseModel): + """Ticket statistics for admin.""" + total: int + open: int + pending: int + answered: int + closed: int + + +def _message_to_response(message: TicketMessage) -> TicketMessageResponse: + """Convert TicketMessage to response.""" + return TicketMessageResponse( + id=message.id, + message_text=message.message_text or "", + is_from_admin=message.is_from_admin, + has_media=bool(message.media_file_id), + media_type=message.media_type, + media_caption=message.media_caption, + created_at=message.created_at, + ) + + +def _user_to_info(user: User) -> AdminTicketUserInfo: + """Convert User to admin info.""" + return AdminTicketUserInfo( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + ) + + +def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse: + """Convert Ticket to admin response.""" + last_message = None + messages_count = len(ticket.messages) if ticket.messages else 0 + + if ticket.messages: + last_msg = max(ticket.messages, key=lambda m: m.created_at) + last_message = _message_to_response(last_msg) + + user_info = None + if hasattr(ticket, 'user') and ticket.user: + user_info = _user_to_info(ticket.user) + + return AdminTicketResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + messages_count=messages_count, + user=user_info, + last_message=last_message, + ) + + +@router.get("/stats", response_model=AdminStatsResponse) +async def get_ticket_stats( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket statistics.""" + # Total count + total_result = await db.execute(select(func.count()).select_from(Ticket)) + total = total_result.scalar() or 0 + + # Count by status + statuses = {} + for status_name in ["open", "pending", "answered", "closed"]: + result = await db.execute( + select(func.count()).select_from(Ticket).where(Ticket.status == status_name) + ) + statuses[status_name] = result.scalar() or 0 + + return AdminStatsResponse( + total=total, + open=statuses.get("open", 0), + pending=statuses.get("pending", 0), + answered=statuses.get("answered", 0), + closed=statuses.get("closed", 0), + ) + + +@router.get("", response_model=AdminTicketListResponse) +async def get_all_tickets( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), + priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get all tickets for admin.""" + # Base query with user relationship + query = ( + select(Ticket) + .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + ) + + # Build count query + count_query = select(func.count()).select_from(Ticket) + + # Apply filters + if status_filter: + query = query.where(Ticket.status == status_filter) + count_query = count_query.where(Ticket.status == status_filter) + + if priority_filter: + query = query.where(Ticket.priority == priority_filter) + count_query = count_query.where(Ticket.priority == priority_filter) + + # Get total count + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Paginate - order by updated_at desc (newest first) + offset = (page - 1) * per_page + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page) + + result = await db.execute(query) + tickets = result.scalars().all() + + items = [_ticket_to_admin_response(t) for t in tickets] + pages = math.ceil(total / per_page) if total > 0 else 1 + + return AdminTicketListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse) +async def get_ticket_detail( + ticket_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket with all messages for admin.""" + query = ( + select(Ticket) + .where(Ticket.id == ticket_id) + .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + ) + + result = await db.execute(query) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + messages = sorted(ticket.messages or [], key=lambda m: m.created_at) + messages_response = [_message_to_response(m) for m in messages] + + user_info = None + if ticket.user: + user_info = _user_to_info(ticket.user) + + return AdminTicketDetailResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + user=user_info, + messages=messages_response, + ) + + +@router.post("/{ticket_id}/reply", response_model=TicketMessageResponse) +async def reply_to_ticket( + ticket_id: int, + request: AdminReplyRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Reply to a ticket as admin.""" + # Get ticket + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True) + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + # Create admin message + message = TicketMessage( + ticket_id=ticket.id, + user_id=ticket.user_id, + message_text=request.message, + is_from_admin=True, + created_at=datetime.utcnow(), + ) + db.add(message) + + # Update ticket status to answered + ticket.status = "answered" + ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(message) + + # Try to notify user via Telegram + try: + from aiogram import Bot + from aiogram.client.default import DefaultBotProperties + from aiogram.enums import ParseMode + + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + try: + from app.handlers.admin.tickets import notify_user_about_ticket_reply + await notify_user_about_ticket_reply(bot, ticket, request.message, db) + except Exception as e: + logger.warning(f"Failed to notify user about ticket reply: {e}") + finally: + await bot.session.close() + except Exception as e: + logger.warning(f"Failed to send Telegram notification: {e}") + + return _message_to_response(message) + + +@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse) +async def update_ticket_status( + ticket_id: int, + request: AdminStatusUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update ticket status.""" + allowed_statuses = {"open", "pending", "answered", "closed"} + if request.status not in allowed_statuses: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status. Allowed: {', '.join(allowed_statuses)}", + ) + + query = ( + select(Ticket) + .where(Ticket.id == ticket_id) + .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + ) + + result = await db.execute(query) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + ticket.status = request.status + ticket.updated_at = datetime.utcnow() + if request.status == "closed": + ticket.closed_at = datetime.utcnow() + else: + ticket.closed_at = None + + await db.commit() + await db.refresh(ticket) + + messages = sorted(ticket.messages or [], key=lambda m: m.created_at) + messages_response = [_message_to_response(m) for m in messages] + + user_info = None + if ticket.user: + user_info = _user_to_info(ticket.user) + + return AdminTicketDetailResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + user=user_info, + messages=messages_response, + ) + + +@router.post("/{ticket_id}/priority", response_model=AdminTicketDetailResponse) +async def update_ticket_priority( + ticket_id: int, + request: AdminPriorityUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update ticket priority.""" + allowed_priorities = {"low", "normal", "high", "urgent"} + if request.priority not in allowed_priorities: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid priority. Allowed: {', '.join(allowed_priorities)}", + ) + + query = ( + select(Ticket) + .where(Ticket.id == ticket_id) + .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + ) + + result = await db.execute(query) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + ticket.priority = request.priority + ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(ticket) + + messages = sorted(ticket.messages or [], key=lambda m: m.created_at) + messages_response = [_message_to_response(m) for m in messages] + + user_info = None + if ticket.user: + user_info = _user_to_info(ticket.user) + + return AdminTicketDetailResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + user=user_info, + messages=messages_response, + ) diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py new file mode 100644 index 00000000..12da5835 --- /dev/null +++ b/app/cabinet/routes/auth.py @@ -0,0 +1,597 @@ +"""Authentication routes for cabinet.""" + +import hashlib +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database.models import User, CabinetRefreshToken +from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.auth import ( + TelegramAuthRequest, + TelegramWidgetAuthRequest, + EmailRegisterRequest, + EmailVerifyRequest, + EmailLoginRequest, + RefreshTokenRequest, + PasswordForgotRequest, + PasswordResetRequest, + TokenResponse, + UserResponse, + AuthResponse, +) +from ..auth import ( + validate_telegram_login_widget, + validate_telegram_init_data, + create_access_token, + create_refresh_token, + get_token_payload, + hash_password, + verify_password, +) +from ..auth.jwt_handler import get_refresh_token_expires_at +from ..auth.email_verification import ( + generate_verification_token, + generate_password_reset_token, + get_verification_expires_at, + get_password_reset_expires_at, + is_token_expired, +) +from ..services.email_service import email_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["Cabinet Auth"]) + + +def _user_to_response(user: User) -> UserResponse: + """Convert User model to UserResponse.""" + return UserResponse( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + email_verified=user.email_verified, + balance_kopeks=user.balance_kopeks, + balance_rubles=user.balance_rubles, + referral_code=user.referral_code, + language=user.language, + created_at=user.created_at, + ) + + +def _create_auth_response(user: User) -> AuthResponse: + """Create full auth response with tokens.""" + access_token = create_access_token(user.id, user.telegram_id) + refresh_token = create_refresh_token(user.id) + expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 + + return AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + expires_in=expires_in, + user=_user_to_response(user), + ) + + +async def _store_refresh_token( + db: AsyncSession, + user_id: int, + refresh_token: str, + device_info: Optional[str] = None, +) -> None: + """Store refresh token hash in database.""" + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + expires_at = get_refresh_token_expires_at() + + token_record = CabinetRefreshToken( + user_id=user_id, + token_hash=token_hash, + device_info=device_info, + expires_at=expires_at, + ) + db.add(token_record) + await db.commit() + + +@router.post("/telegram", response_model=AuthResponse) +async def auth_telegram( + request: TelegramAuthRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Authenticate using Telegram WebApp initData. + + This endpoint validates the initData from Telegram WebApp and returns + JWT tokens for authenticated access. + """ + user_data = validate_telegram_init_data(request.init_data) + + if not user_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired Telegram authentication data", + ) + + telegram_id = user_data.get("id") + if not telegram_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Telegram user ID", + ) + + user = await get_user_by_telegram_id(db, telegram_id) + + # Get user data from initData + tg_username = user_data.get("username") + tg_first_name = user_data.get("first_name") + tg_last_name = user_data.get("last_name") + tg_language = user_data.get("language_code", "ru") + + if not user: + # Create new user from Telegram initData + logger.info(f"Creating new user from cabinet (initData): telegram_id={telegram_id}") + user = await create_user( + db=db, + telegram_id=telegram_id, + username=tg_username, + first_name=tg_first_name, + last_name=tg_last_name, + language=tg_language, + ) + logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}") + else: + # Update user info from initData (like bot middleware does) + updated = False + if tg_username and tg_username != user.username: + user.username = tg_username + updated = True + if tg_first_name and tg_first_name != user.first_name: + user.first_name = tg_first_name + updated = True + if tg_last_name and tg_last_name != user.last_name: + user.last_name = tg_last_name + updated = True + if updated: + logger.info(f"User {user.id} profile updated from initData") + + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is not active", + ) + + # Update last login + user.cabinet_last_login = datetime.utcnow() + await db.commit() + + response = _create_auth_response(user) + + # Store refresh token + await _store_refresh_token(db, user.id, response.refresh_token) + + return response + + +@router.post("/telegram/widget", response_model=AuthResponse) +async def auth_telegram_widget( + request: TelegramWidgetAuthRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Authenticate using Telegram Login Widget data. + + This endpoint validates data from Telegram Login Widget and returns + JWT tokens for authenticated access. + """ + widget_data = request.model_dump() + + if not validate_telegram_login_widget(widget_data): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired Telegram authentication data", + ) + + user = await get_user_by_telegram_id(db, request.id) + + if not user: + # Create new user from Telegram data + logger.info(f"Creating new user from cabinet: telegram_id={request.id}, username={request.username}") + user = await create_user( + db=db, + telegram_id=request.id, + username=request.username, + first_name=request.first_name, + last_name=request.last_name, + language="ru", + ) + logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}") + + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is not active", + ) + + # Update user info from widget data + if request.username and request.username != user.username: + user.username = request.username + if request.first_name and request.first_name != user.first_name: + user.first_name = request.first_name + if request.last_name != user.last_name: + user.last_name = request.last_name + + user.cabinet_last_login = datetime.utcnow() + await db.commit() + + response = _create_auth_response(user) + await _store_refresh_token(db, user.id, response.refresh_token) + + return response + + +@router.post("/email/register") +async def register_email( + request: EmailRegisterRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Register/link email to existing Telegram account. + + Requires valid JWT token from Telegram authentication. + Sends verification email to the provided address. + """ + # Check if email already exists + existing_user = await db.execute( + select(User).where(User.email == request.email) + ) + if existing_user.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This email is already registered", + ) + + # Check if user already has email + if user.email and user.email_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You already have a verified email", + ) + + # Generate verification token + verification_token = generate_verification_token() + verification_expires = get_verification_expires_at() + + # Update user + user.email = request.email + user.email_verified = False + user.password_hash = hash_password(request.password) + user.email_verification_token = verification_token + user.email_verification_expires = verification_expires + + await db.commit() + + # Send verification email + if email_service.is_configured(): + # TODO: Get actual verification URL from settings + verification_url = "https://example.com/cabinet/verify-email" + email_service.send_verification_email( + to_email=request.email, + verification_token=verification_token, + verification_url=verification_url, + username=user.first_name, + ) + + return { + "message": "Verification email sent", + "email": request.email, + } + + +@router.post("/email/verify") +async def verify_email( + request: EmailVerifyRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Verify email with token.""" + # Find user with this token + result = await db.execute( + select(User).where(User.email_verification_token == request.token) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid verification token", + ) + + if is_token_expired(user.email_verification_expires): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verification token has expired", + ) + + # Mark email as verified + user.email_verified = True + user.email_verified_at = datetime.utcnow() + user.email_verification_token = None + user.email_verification_expires = None + + await db.commit() + + return {"message": "Email verified successfully"} + + +@router.post("/email/resend") +async def resend_verification( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Resend verification email.""" + if not user.email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No email address to verify", + ) + + if user.email_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is already verified", + ) + + # Generate new token + verification_token = generate_verification_token() + verification_expires = get_verification_expires_at() + + user.email_verification_token = verification_token + user.email_verification_expires = verification_expires + + await db.commit() + + # Send verification email + if email_service.is_configured(): + verification_url = "https://example.com/cabinet/verify-email" + email_service.send_verification_email( + to_email=user.email, + verification_token=verification_token, + verification_url=verification_url, + username=user.first_name, + ) + + return {"message": "Verification email sent"} + + +@router.post("/email/login", response_model=AuthResponse) +async def login_email( + request: EmailLoginRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Login with email and password.""" + # Find user by email + result = await db.execute( + select(User).where(User.email == request.email) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + if not user.password_hash: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Password login not configured for this account", + ) + + if not verify_password(request.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + if not user.email_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Please verify your email first", + ) + + if user.status != "active": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is not active", + ) + + user.cabinet_last_login = datetime.utcnow() + await db.commit() + + response = _create_auth_response(user) + await _store_refresh_token(db, user.id, response.refresh_token) + + return response + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + request: RefreshTokenRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Refresh access token using refresh token.""" + payload = get_token_payload(request.refresh_token, expected_type="refresh") + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + try: + user_id = int(payload.get("sub")) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + + # Verify token exists in database and is not revoked + token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest() + result = await db.execute( + select(CabinetRefreshToken).where( + CabinetRefreshToken.token_hash == token_hash, + CabinetRefreshToken.revoked_at.is_(None), + ) + ) + token_record = result.scalar_one_or_none() + + if not token_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token not found or revoked", + ) + + if not token_record.is_valid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token is no longer valid", + ) + + user = await get_user_by_id(db, user_id) + + if not user or user.status != "active": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + + access_token = create_access_token(user.id, user.telegram_id) + expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 + + return TokenResponse( + access_token=access_token, + refresh_token=request.refresh_token, + token_type="bearer", + expires_in=expires_in, + ) + + +@router.post("/logout") +async def logout( + request: RefreshTokenRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Logout and revoke refresh token.""" + token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest() + + result = await db.execute( + select(CabinetRefreshToken).where( + CabinetRefreshToken.token_hash == token_hash, + ) + ) + token_record = result.scalar_one_or_none() + + if token_record: + token_record.revoked_at = datetime.utcnow() + await db.commit() + + return {"message": "Logged out successfully"} + + +@router.post("/password/forgot") +async def forgot_password( + request: PasswordForgotRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Request password reset.""" + result = await db.execute( + select(User).where(User.email == request.email) + ) + user = result.scalar_one_or_none() + + # Always return success to prevent email enumeration + if not user or not user.email_verified: + return {"message": "If the email exists, a password reset link has been sent"} + + # Generate reset token + reset_token = generate_password_reset_token() + reset_expires = get_password_reset_expires_at() + + user.password_reset_token = reset_token + user.password_reset_expires = reset_expires + + await db.commit() + + # Send reset email + if email_service.is_configured(): + reset_url = "https://example.com/cabinet/reset-password" + email_service.send_password_reset_email( + to_email=user.email, + reset_token=reset_token, + reset_url=reset_url, + username=user.first_name, + ) + + return {"message": "If the email exists, a password reset link has been sent"} + + +@router.post("/password/reset") +async def reset_password( + request: PasswordResetRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Reset password with token.""" + result = await db.execute( + select(User).where(User.password_reset_token == request.token) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid reset token", + ) + + if is_token_expired(user.password_reset_expires): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Reset token has expired", + ) + + # Update password + user.password_hash = hash_password(request.password) + user.password_reset_token = None + user.password_reset_expires = None + + await db.commit() + + return {"message": "Password reset successfully"} + + +@router.get("/me", response_model=UserResponse) +async def get_current_user( + user: User = Depends(get_current_cabinet_user), +): + """Get current authenticated user info.""" + return _user_to_response(user) + + +@router.get("/me/is-admin") +async def check_is_admin( + user: User = Depends(get_current_cabinet_user), +): + """Check if current user is an admin.""" + is_admin = settings.is_admin(user.telegram_id) + return {"is_admin": is_admin} diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py new file mode 100644 index 00000000..ae894d0d --- /dev/null +++ b/app/cabinet/routes/balance.py @@ -0,0 +1,316 @@ +"""Balance and payment routes for cabinet.""" + +import logging +import math +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc + +from app.database.models import User, Transaction +from app.config import settings +from app.services.yookassa_service import YooKassaService +from app.external.cryptobot import CryptoBotService +from app.database.crud.user import get_user_by_id + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.balance import ( + BalanceResponse, + TransactionResponse, + TransactionListResponse, + PaymentMethodResponse, + TopUpRequest, + TopUpResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/balance", tags=["Cabinet Balance"]) + + +@router.get("", response_model=BalanceResponse) +async def get_balance( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get current user's balance.""" + # Reload user from current session to get fresh data + # (user object is from different session in get_current_cabinet_user) + fresh_user = await get_user_by_id(db, user.id) + if not fresh_user: + raise HTTPException(status_code=404, detail="User not found") + + return BalanceResponse( + balance_kopeks=fresh_user.balance_kopeks, + balance_rubles=fresh_user.balance_kopeks / 100, + ) + + +@router.get("/transactions", response_model=TransactionListResponse) +async def get_transactions( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + type: Optional[str] = Query(None, description="Filter by transaction type"), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get transaction history.""" + # Base query + query = select(Transaction).where(Transaction.user_id == user.id) + + # Filter by type + if type: + query = query.where(Transaction.type == type) + + # Get total count + count_query = select(func.count()).select_from(Transaction).where(Transaction.user_id == user.id) + if type: + count_query = count_query.where(Transaction.type == type) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Paginate + offset = (page - 1) * per_page + query = query.order_by(desc(Transaction.created_at)).offset(offset).limit(per_page) + + result = await db.execute(query) + transactions = result.scalars().all() + + items = [ + TransactionResponse( + id=t.id, + type=t.type, + amount_kopeks=t.amount_kopeks, + amount_rubles=t.amount_kopeks / 100, + description=t.description, + payment_method=t.payment_method, + is_completed=t.is_completed, + created_at=t.created_at, + completed_at=t.completed_at, + ) + for t in transactions + ] + + pages = math.ceil(total / per_page) if total > 0 else 1 + + return TransactionListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.get("/payment-methods", response_model=List[PaymentMethodResponse]) +async def get_payment_methods(): + """Get available payment methods.""" + methods = [] + + # YooKassa + if settings.is_yookassa_enabled(): + methods.append(PaymentMethodResponse( + id="yookassa", + name="YooKassa (Bank Card)", + description="Pay with bank card via YooKassa", + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + # CryptoBot + if settings.is_cryptobot_enabled(): + methods.append(PaymentMethodResponse( + id="cryptobot", + name="CryptoBot", + description="Pay with cryptocurrency via CryptoBot", + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + )) + + # Telegram Stars + if settings.TELEGRAM_STARS_ENABLED: + methods.append(PaymentMethodResponse( + id="telegram_stars", + name="Telegram Stars", + description="Pay with Telegram Stars", + min_amount_kopeks=100, + max_amount_kopeks=1000000, + is_available=True, + )) + + # Heleket + if settings.is_heleket_enabled(): + methods.append(PaymentMethodResponse( + id="heleket", + name="Heleket Crypto", + description="Pay with cryptocurrency via Heleket", + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + )) + + # MulenPay + if settings.is_mulenpay_enabled(): + methods.append(PaymentMethodResponse( + id="mulenpay", + name=settings.get_mulenpay_display_name(), + description="MulenPay payment", + min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + # PAL24 + if settings.is_pal24_enabled(): + methods.append(PaymentMethodResponse( + id="pal24", + name="PAL24", + description="Pay via PAL24", + min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + # Platega + if settings.is_platega_enabled(): + methods.append(PaymentMethodResponse( + id="platega", + name="Platega", + description="Pay via Platega", + min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + # Wata + if settings.is_wata_enabled(): + methods.append(PaymentMethodResponse( + id="wata", + name="Wata", + description="Pay via Wata", + min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + return methods + + +@router.post("/topup", response_model=TopUpResponse) +async def create_topup( + request: TopUpRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Create payment for balance top-up.""" + # Validate payment method + methods = await get_payment_methods() + method = next((m for m in methods if m.id == request.payment_method), None) + + if not method or not method.is_available: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or unavailable payment method", + ) + + # Validate amount + if request.amount_kopeks < method.min_amount_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Minimum amount is {method.min_amount_kopeks / 100:.2f} RUB", + ) + + if request.amount_kopeks > method.max_amount_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum amount is {method.max_amount_kopeks / 100:.2f} RUB", + ) + + amount_rubles = request.amount_kopeks / 100 + payment_url = None + payment_id = None + + try: + if request.payment_method == "yookassa": + yookassa_service = YooKassaService() + result = await yookassa_service.create_payment( + amount=amount_rubles, + currency="RUB", + description=f"Пополнение баланса на {amount_rubles:.2f} ₽", + metadata={ + "user_id": str(user.id), + "amount_kopeks": str(request.amount_kopeks), + "type": "balance_topup", + "source": "cabinet", + }, + ) + if result and not result.get("error"): + payment_url = result.get("confirmation_url") + payment_id = result.get("id") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create YooKassa payment", + ) + + elif request.payment_method == "cryptobot": + cryptobot_service = CryptoBotService() + # Convert RUB to USDT (approximate) + usdt_amount = amount_rubles / 100 # Approximate rate + result = await cryptobot_service.create_invoice( + amount=usdt_amount, + asset="USDT", + description=f"Balance top-up {amount_rubles:.2f} RUB", + payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}", + ) + if result: + payment_url = result.get("pay_url") or result.get("bot_invoice_url") + payment_id = str(result.get("invoice_id")) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create CryptoBot invoice", + ) + + elif request.payment_method == "telegram_stars": + # Telegram Stars payments require bot interaction + bot_username = settings.get_bot_username() or "bot" + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Telegram Stars payments are only available through the bot. Please use @{bot_username}", + ) + + else: + # For other payment methods, redirect to bot + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This payment method is only available through the Telegram bot.", + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Payment creation error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create payment. Please try again later.", + ) + + if not payment_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Payment URL not received", + ) + + return TopUpResponse( + payment_id=payment_id or "pending", + payment_url=payment_url, + amount_kopeks=request.amount_kopeks, + amount_rubles=amount_rubles, + status="pending", + expires_at=None, + ) diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py new file mode 100644 index 00000000..5c363e39 --- /dev/null +++ b/app/cabinet/routes/branding.py @@ -0,0 +1,281 @@ +"""Branding routes for cabinet - logo and project name management.""" + +import logging +import os +import base64 +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database.models import User, SystemSetting +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_admin_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/branding", tags=["Branding"]) + +# Directory for storing branding assets +BRANDING_DIR = Path("data/branding") +LOGO_FILENAME = "logo.png" + +# Settings keys +BRANDING_NAME_KEY = "CABINET_BRANDING_NAME" +BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default" + +# Allowed image types +ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"} +MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB + + +# ============ Schemas ============ + +class BrandingResponse(BaseModel): + """Current branding settings.""" + name: str + logo_url: Optional[str] = None + logo_letter: str + has_custom_logo: bool + + +class BrandingNameUpdate(BaseModel): + """Request to update branding name.""" + name: str + + +# ============ Helper Functions ============ + +def ensure_branding_dir(): + """Ensure branding directory exists.""" + BRANDING_DIR.mkdir(parents=True, exist_ok=True) + + +async def get_setting_value(db: AsyncSession, key: str) -> Optional[str]: + """Get a setting value from database.""" + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == key) + ) + setting = result.scalar_one_or_none() + return setting.value if setting else None + + +async def set_setting_value(db: AsyncSession, key: str, value: str): + """Set a setting value in database.""" + result = await db.execute( + select(SystemSetting).where(SystemSetting.key == key) + ) + setting = result.scalar_one_or_none() + + if setting: + setting.value = value + else: + setting = SystemSetting(key=key, value=value) + db.add(setting) + + await db.commit() + + +def get_logo_path() -> Path: + """Get the path to the custom logo file.""" + return BRANDING_DIR / LOGO_FILENAME + + +def has_custom_logo() -> bool: + """Check if a custom logo exists.""" + return get_logo_path().exists() + + +# ============ Routes ============ + +@router.get("", response_model=BrandingResponse) +async def get_branding( + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Get current branding settings. + This is a public endpoint - no authentication required. + """ + # Get name from database or use default from env/settings + name = await get_setting_value(db, BRANDING_NAME_KEY) + if name is None: # Only use fallback if not set at all (empty string is valid) + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ + os.getenv('VITE_APP_NAME', 'Cabinet') + + # Check for custom logo + custom_logo = has_custom_logo() + + # Get first letter for logo fallback (use "V" if name is empty) + logo_letter = name[0].upper() if name else "V" + + return BrandingResponse( + name=name, + logo_url="/cabinet/branding/logo" if custom_logo else None, + logo_letter=logo_letter, + has_custom_logo=custom_logo, + ) + + +@router.get("/logo") +async def get_logo(): + """ + Get the custom logo image. + Returns 404 if no custom logo is set. + """ + logo_path = get_logo_path() + + if not logo_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No custom logo set" + ) + + # Determine media type from file extension + suffix = logo_path.suffix.lower() + media_types = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".svg": "image/svg+xml", + } + media_type = media_types.get(suffix, "image/png") + + return FileResponse( + logo_path, + media_type=media_type, + headers={"Cache-Control": "public, max-age=3600"} + ) + + +@router.put("/name", response_model=BrandingResponse) +async def update_branding_name( + payload: BrandingNameUpdate, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update the project name. Admin only. Empty name allowed (logo only mode).""" + name = payload.name.strip() if payload.name else "" + + if len(name) > 50: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Name too long (max 50 characters)" + ) + + await set_setting_value(db, BRANDING_NAME_KEY, name) + + logger.info(f"Admin {admin.telegram_id} updated branding name to: {name}") + + # Return updated branding + custom_logo = has_custom_logo() + logo_letter = name[0].upper() if name else "C" + + return BrandingResponse( + name=name, + logo_url="/cabinet/branding/logo" if custom_logo else None, + logo_letter=logo_letter, + has_custom_logo=custom_logo, + ) + + +@router.post("/logo", response_model=BrandingResponse) +async def upload_logo( + file: UploadFile = File(...), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Upload a custom logo. Admin only.""" + # Validate content type + if file.content_type not in ALLOWED_CONTENT_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG" + ) + + # Read file content + content = await file.read() + + # Validate file size + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB" + ) + + # Ensure directory exists + ensure_branding_dir() + + # Determine file extension from content type + ext_map = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + "image/svg+xml": ".svg", + } + extension = ext_map.get(file.content_type, ".png") + + # Remove old logo files with any extension + for old_file in BRANDING_DIR.glob("logo.*"): + old_file.unlink() + + # Save new logo + logo_path = BRANDING_DIR / f"logo{extension}" + logo_path.write_bytes(content) + + # Mark that we have a custom logo + await set_setting_value(db, BRANDING_LOGO_KEY, "custom") + + logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}") + + # Get current name for response + name = await get_setting_value(db, BRANDING_NAME_KEY) + if name is None: # Only use fallback if not set at all (empty string is valid) + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ + os.getenv('VITE_APP_NAME', 'Cabinet') + + logo_letter = name[0].upper() if name else "C" + + return BrandingResponse( + name=name, + logo_url="/cabinet/branding/logo", + logo_letter=logo_letter, + has_custom_logo=True, + ) + + +@router.delete("/logo", response_model=BrandingResponse) +async def delete_logo( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Delete custom logo and revert to letter. Admin only.""" + # Remove logo files + for old_file in BRANDING_DIR.glob("logo.*"): + old_file.unlink() + + # Update setting + await set_setting_value(db, BRANDING_LOGO_KEY, "default") + + logger.info(f"Admin {admin.telegram_id} deleted custom logo") + + # Get current name for response + name = await get_setting_value(db, BRANDING_NAME_KEY) + if name is None: # Only use fallback if not set at all (empty string is valid) + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ + os.getenv('VITE_APP_NAME', 'Cabinet') + + logo_letter = name[0].upper() if name else "C" + + return BrandingResponse( + name=name, + logo_url=None, + logo_letter=logo_letter, + has_custom_logo=False, + ) diff --git a/app/cabinet/routes/contests.py b/app/cabinet/routes/contests.py new file mode 100644 index 00000000..5fff0120 --- /dev/null +++ b/app/cabinet/routes/contests.py @@ -0,0 +1,387 @@ +"""Contests routes for cabinet - user participation in games/contests.""" + +import logging +import random +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, SubscriptionStatus +from app.database.crud.contest import ( + get_active_rounds, + get_attempt, + create_attempt, + increment_winner_count, +) +from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription +from app.services.contest_rotation_service import ( + GAME_QUEST, + GAME_LOCKS, + GAME_CIPHER, + GAME_SERVER, + GAME_BLITZ, + GAME_EMOJI, + GAME_ANAGRAM, +) + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/contests", tags=["Cabinet Contests"]) + + +# ============ Schemas ============ + +class ContestInfo(BaseModel): + """Contest/game info.""" + id: int + slug: str + name: str + description: Optional[str] = None + prize_days: int + is_available: bool + already_played: bool = False + + +class ContestGameData(BaseModel): + """Data for playing a contest game.""" + round_id: int + game_type: str + game_data: Dict[str, Any] + instructions: str + + +class ContestAnswerRequest(BaseModel): + """Request to submit contest answer.""" + round_id: int + answer: str + + +class ContestResult(BaseModel): + """Result of contest attempt.""" + is_winner: bool + message: str + prize_days: Optional[int] = None + + +# ============ Helpers ============ + +def _user_allowed(subscription) -> bool: + """Check if user is allowed to participate in contests.""" + if not subscription: + return False + return subscription.status in { + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + } + + +async def _award_prize(db: AsyncSession, user_id: int, prize_days: int) -> str: + """Award prize to winner.""" + subscription = await get_subscription_by_user_id(db, user_id) + if not subscription: + return "Error: subscription not found" + + subscription.end_date = subscription.end_date + timedelta(days=prize_days) + subscription.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + logger.info(f"🎁 Extended subscription for user {user_id} by {prize_days} days (contest prize)") + return f"Subscription extended by {prize_days} days" + + +# ============ Routes ============ + +class ContestsCountResponse(BaseModel): + """Count of available contests.""" + count: int + + +@router.get("/count", response_model=ContestsCountResponse) +async def get_contests_count( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get count of contests available for the user.""" + subscription = await get_subscription_by_user_id(db, user.id) + + if not _user_allowed(subscription): + return ContestsCountResponse(count=0) + + active_rounds = await get_active_rounds(db) + + # Count unique available contests (not yet played) + count = 0 + seen_templates = set() + for rnd in active_rounds: + if not rnd.template or not rnd.template.is_enabled: + continue + tpl_slug = rnd.template.slug if rnd.template else "" + if tpl_slug in seen_templates: + continue + seen_templates.add(tpl_slug) + + # Check if user already played this round + attempt = await get_attempt(db, rnd.id, user.id) + if not attempt: + count += 1 + + return ContestsCountResponse(count=count) + + +@router.get("", response_model=List[ContestInfo]) +async def get_contests( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of available contests/games.""" + subscription = await get_subscription_by_user_id(db, user.id) + + if not _user_allowed(subscription): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Contests are only available for users with active or trial subscriptions", + ) + + active_rounds = await get_active_rounds(db) + + # Group by template to avoid duplicates + unique_templates = {} + for rnd in active_rounds: + if not rnd.template or not rnd.template.is_enabled: + continue + tpl_slug = rnd.template.slug if rnd.template else "" + if tpl_slug not in unique_templates: + unique_templates[tpl_slug] = rnd + + contests = [] + for tpl_slug, rnd in unique_templates.items(): + # Check if user already played this round + attempt = await get_attempt(db, rnd.id, user.id) + + contests.append(ContestInfo( + id=rnd.id, + slug=tpl_slug, + name=rnd.template.name if rnd.template else tpl_slug, + description=rnd.template.description if rnd.template else None, + prize_days=rnd.template.prize_days if rnd.template else 0, + is_available=True, + already_played=attempt is not None, + )) + + return contests + + +@router.get("/{round_id}", response_model=ContestGameData) +async def get_contest_game( + round_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get game data for a specific contest round.""" + subscription = await get_subscription_by_user_id(db, user.id) + + if not _user_allowed(subscription): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Contests are only available for users with active or trial subscriptions", + ) + + active_rounds = await get_active_rounds(db) + round_obj = next((r for r in active_rounds if r.id == round_id), None) + + if not round_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contest round not found or already finished", + ) + + if not round_obj.template or not round_obj.template.is_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This contest is disabled", + ) + + # Check if already played + attempt = await get_attempt(db, round_id, user.id) + if attempt: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You have already played this round", + ) + + tpl = round_obj.template + game_type = tpl.slug + game_data = {} + instructions = "" + + if game_type == GAME_QUEST: + rows = round_obj.payload.get("rows", 3) + cols = round_obj.payload.get("cols", 3) + secret = random.randint(0, rows * cols - 1) + game_data = { + "rows": rows, + "cols": cols, + "secret": secret, + "grid_size": rows * cols, + } + instructions = "Select one of the nodes in the grid. Find the hidden server!" + + elif game_type == GAME_LOCKS: + total = round_obj.payload.get("total", 20) + secret = random.randint(0, total - 1) + game_data = { + "total": total, + "secret": secret, + } + instructions = "Find the unlocked button among the locks!" + + elif game_type == GAME_SERVER: + flags = round_obj.payload.get("flags") or [] + shuffled_flags = flags.copy() + random.shuffle(shuffled_flags) + game_data = { + "flags": shuffled_flags, + } + instructions = "Choose a server by clicking on a flag!" + + elif game_type == GAME_CIPHER: + question = round_obj.payload.get("question", "") + game_data = { + "question": question, + "input_type": "text", + } + instructions = "Decrypt the cipher and enter the answer!" + + elif game_type == GAME_EMOJI: + question = round_obj.payload.get("question", "🤔") + emoji_list = question.split() + random.shuffle(emoji_list) + game_data = { + "question": " ".join(emoji_list), + "input_type": "text", + } + instructions = "Guess the service by emojis!" + + elif game_type == GAME_ANAGRAM: + letters = round_obj.payload.get("letters", "") + game_data = { + "letters": letters, + "input_type": "text", + } + instructions = "Make a word from the given letters!" + + elif game_type == GAME_BLITZ: + game_data = { + "button_text": "I'm here!", + } + instructions = "Click the button as fast as you can!" + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unknown contest type", + ) + + return ContestGameData( + round_id=round_id, + game_type=game_type, + game_data=game_data, + instructions=instructions, + ) + + +@router.post("/{round_id}/answer", response_model=ContestResult) +async def submit_contest_answer( + round_id: int, + request: ContestAnswerRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Submit answer for a contest round.""" + subscription = await get_subscription_by_user_id(db, user.id) + + if not _user_allowed(subscription): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Contests are only available for users with active or trial subscriptions", + ) + + active_rounds = await get_active_rounds(db) + round_obj = next((r for r in active_rounds if r.id == round_id), None) + + if not round_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contest round not found or already finished", + ) + + # Check if already played + attempt = await get_attempt(db, round_id, user.id) + if attempt: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You have already played this round", + ) + + tpl = round_obj.template + answer = request.answer + is_winner = False + + # Determine if winner based on game type + if tpl.slug == GAME_SERVER: + flags = round_obj.payload.get("flags") or [] + secret_idx = round_obj.payload.get("secret_idx") + correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else "" + is_winner = answer == correct_flag + + elif tpl.slug in {GAME_QUEST, GAME_LOCKS}: + try: + parts = answer.split("_") + if len(parts) >= 2: + idx = int(parts[0]) + secret = int(parts[1]) + is_winner = idx == secret + except (ValueError, IndexError): + is_winner = False + + elif tpl.slug == GAME_BLITZ: + is_winner = answer.lower() == "blitz" + + elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}: + correct = (round_obj.payload.get("answer") or "").upper() + is_winner = correct and answer.upper() == correct + + # Record attempt + await create_attempt( + db, + round_id=round_obj.id, + user_id=user.id, + answer=str(answer), + is_winner=is_winner + ) + + if is_winner: + await increment_winner_count(db, round_obj) + prize_text = await _award_prize(db, user.id, tpl.prize_days) + return ContestResult( + is_winner=True, + message=f"🎉 Congratulations! You won! {prize_text}", + prize_days=tpl.prize_days, + ) + else: + lose_messages = { + GAME_QUEST: ["Empty node", "Wrong server", "Try another"], + GAME_LOCKS: ["Locked", "No access", "Try again"], + GAME_SERVER: ["Server overloaded", "No response", "Try tomorrow"], + } + messages = lose_messages.get(tpl.slug, ["Incorrect", "Try again next round"]) + return ContestResult( + is_winner=False, + message=random.choice(messages), + ) diff --git a/app/cabinet/routes/info.py b/app/cabinet/routes/info.py new file mode 100644 index 00000000..8554e634 --- /dev/null +++ b/app/cabinet/routes/info.py @@ -0,0 +1,237 @@ +"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc.""" + +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.config import settings +from app.services.faq_service import FaqService +from app.services.privacy_policy_service import PrivacyPolicyService +from app.services.public_offer_service import PublicOfferService +from app.database.crud.rules import get_rules_by_language, get_current_rules_content + +from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/info", tags=["Cabinet Info"]) + + +# ============ Schemas ============ + +class FaqPageResponse(BaseModel): + """FAQ page.""" + id: int + title: str + content: str + order: int + + +class RulesResponse(BaseModel): + """Service rules.""" + content: str + updated_at: Optional[str] = None + + +class PrivacyPolicyResponse(BaseModel): + """Privacy policy.""" + content: str + updated_at: Optional[str] = None + + +class PublicOfferResponse(BaseModel): + """Public offer.""" + content: str + updated_at: Optional[str] = None + + +class ServiceInfoResponse(BaseModel): + """General service info.""" + name: str + description: Optional[str] = None + support_email: Optional[str] = None + support_telegram: Optional[str] = None + website: Optional[str] = None + + +# ============ Routes ============ + +@router.get("/faq", response_model=List[FaqPageResponse]) +async def get_faq_pages( + language: str = Query("ru", min_length=2, max_length=10), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of FAQ pages.""" + requested_lang = FaqService.normalize_language(language) + pages = await FaqService.get_pages( + db, + requested_lang, + include_inactive=False, # Only active pages for cabinet + fallback=True, + ) + + return [ + FaqPageResponse( + id=page.id, + title=page.title, + content=page.content or "", + order=page.display_order or 0, + ) + for page in pages + ] + + +@router.get("/faq/{page_id}", response_model=FaqPageResponse) +async def get_faq_page( + page_id: int, + language: str = Query("ru", min_length=2, max_length=10), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get a specific FAQ page by ID.""" + requested_lang = FaqService.normalize_language(language) + page = await FaqService.get_page( + db, + page_id, + requested_lang, + include_inactive=False, + fallback=True, + ) + + if not page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="FAQ page not found", + ) + + return FaqPageResponse( + id=page.id, + title=page.title, + content=page.content or "", + order=page.display_order or 0, + ) + + +@router.get("/rules", response_model=RulesResponse) +async def get_rules( + language: str = Query("ru", min_length=2, max_length=10), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get service rules - uses same function as bot.""" + requested_lang = language.split("-")[0].lower() + + # Use the same function as bot to ensure consistent content + content = await get_current_rules_content(db, requested_lang) + + # Try to get updated_at from DB record + rules = await get_rules_by_language(db, requested_lang) + updated_at = None + if rules and rules.updated_at: + updated_at = rules.updated_at.isoformat() + + return RulesResponse(content=content, updated_at=updated_at) + + +@router.get("/privacy-policy", response_model=PrivacyPolicyResponse) +async def get_privacy_policy( + language: str = Query("ru", min_length=2, max_length=10), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get privacy policy.""" + requested_lang = PrivacyPolicyService.normalize_language(language) + policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True) + + if policy and policy.content: + updated_at = policy.updated_at.isoformat() if policy.updated_at else None + return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at) + + # Return default policy if none found + return PrivacyPolicyResponse( + content="""# Политика конфиденциальности + +Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные. +""", + updated_at=None, + ) + + +@router.get("/public-offer", response_model=PublicOfferResponse) +async def get_public_offer( + language: str = Query("ru", min_length=2, max_length=10), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get public offer.""" + requested_lang = PublicOfferService.normalize_language(language) + offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True) + + if offer and offer.content: + updated_at = offer.updated_at.isoformat() if offer.updated_at else None + return PublicOfferResponse(content=offer.content, updated_at=updated_at) + + # Return default offer if none found + return PublicOfferResponse( + content="""# Публичная оферта + +Условия использования сервиса. +""", + updated_at=None, + ) + + +@router.get("/service", response_model=ServiceInfoResponse) +async def get_service_info(): + """Get general service information.""" + return ServiceInfoResponse( + name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'), + description=getattr(settings, 'SERVICE_DESCRIPTION', None), + support_email=getattr(settings, 'SUPPORT_EMAIL', None), + support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None), + website=getattr(settings, 'WEBSITE_URL', None), + ) + + +@router.get("/languages") +async def get_available_languages(): + """Get list of available languages.""" + return { + "languages": [ + {"code": "ru", "name": "Русский", "flag": "🇷🇺"}, + {"code": "en", "name": "English", "flag": "🇬🇧"}, + ], + "default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru', + } + + +@router.get("/user/language") +async def get_user_language( + user: User = Depends(get_current_cabinet_user), +): + """Get current user's language.""" + return {"language": user.language or "ru"} + + +@router.patch("/user/language") +async def update_user_language( + request: Dict[str, str], + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update user's language preference.""" + language = request.get("language", "ru") + + valid_languages = ["ru", "en"] + if language not in valid_languages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid language. Supported: {', '.join(valid_languages)}", + ) + + user.language = language + await db.commit() + await db.refresh(user) + + return {"language": user.language} diff --git a/app/cabinet/routes/notifications.py b/app/cabinet/routes/notifications.py new file mode 100644 index 00000000..0404d80a --- /dev/null +++ b/app/cabinet/routes/notifications.py @@ -0,0 +1,145 @@ +"""Notification settings routes for cabinet.""" + +import logging +from datetime import datetime +from typing import Optional, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/notifications", tags=["Cabinet Notifications"]) + + +# ============ Schemas ============ + +class NotificationSettingsResponse(BaseModel): + """User notification settings.""" + subscription_expiry_enabled: bool = True + subscription_expiry_days: int = 3 + traffic_warning_enabled: bool = True + traffic_warning_percent: int = 80 + balance_low_enabled: bool = True + balance_low_threshold: int = 100 # kopeks + news_enabled: bool = True + promo_offers_enabled: bool = True + + +class NotificationSettingsUpdate(BaseModel): + """Update notification settings.""" + subscription_expiry_enabled: Optional[bool] = None + subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30) + traffic_warning_enabled: Optional[bool] = None + traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99) + balance_low_enabled: Optional[bool] = None + balance_low_threshold: Optional[int] = Field(None, ge=0) + news_enabled: Optional[bool] = None + promo_offers_enabled: Optional[bool] = None + + +# ============ Helpers ============ + +def _get_notification_settings(user: User) -> Dict[str, Any]: + """Get notification settings from user object.""" + # Try to get from user's settings field or use defaults + settings_data = getattr(user, 'notification_settings', None) or {} + + return { + "subscription_expiry_enabled": settings_data.get("subscription_expiry_enabled", True), + "subscription_expiry_days": settings_data.get("subscription_expiry_days", 3), + "traffic_warning_enabled": settings_data.get("traffic_warning_enabled", True), + "traffic_warning_percent": settings_data.get("traffic_warning_percent", 80), + "balance_low_enabled": settings_data.get("balance_low_enabled", True), + "balance_low_threshold": settings_data.get("balance_low_threshold", 100), + "news_enabled": settings_data.get("news_enabled", True), + "promo_offers_enabled": settings_data.get("promo_offers_enabled", True), + } + + +def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update notification settings on user object.""" + current_settings = _get_notification_settings(user) + + for key, value in updates.items(): + if value is not None: + current_settings[key] = value + + return current_settings + + +# ============ Routes ============ + +@router.get("", response_model=NotificationSettingsResponse) +async def get_notification_settings( + user: User = Depends(get_current_cabinet_user), +): + """Get user's notification settings.""" + settings = _get_notification_settings(user) + return NotificationSettingsResponse(**settings) + + +@router.patch("", response_model=NotificationSettingsResponse) +async def update_notification_settings( + request: NotificationSettingsUpdate, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update user's notification settings.""" + updates = request.model_dump(exclude_unset=True) + + if not updates: + # No updates provided, return current settings + settings = _get_notification_settings(user) + return NotificationSettingsResponse(**settings) + + # Update settings + new_settings = _update_notification_settings(user, updates) + + # Store in user object + if not hasattr(user, 'notification_settings') or user.notification_settings is None: + user.notification_settings = {} + + user.notification_settings = new_settings + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + return NotificationSettingsResponse(**new_settings) + + +@router.post("/test") +async def send_test_notification( + user: User = Depends(get_current_cabinet_user), +): + """Send a test notification to the user.""" + # This would typically trigger a notification via Telegram bot + # For now, just return success + return { + "success": True, + "message": "Test notification request received. You will receive a test message shortly.", + } + + +@router.get("/history") +async def get_notification_history( + limit: int = 20, + offset: int = 0, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get user's notification history.""" + # For now, return empty list - notification history can be implemented later + # when there's a notification log table + return { + "notifications": [], + "total": 0, + "limit": limit, + "offset": offset, + } diff --git a/app/cabinet/routes/polls.py b/app/cabinet/routes/polls.py new file mode 100644 index 00000000..414dc18d --- /dev/null +++ b/app/cabinet/routes/polls.py @@ -0,0 +1,353 @@ +"""Polls routes for cabinet - user participation in polls/surveys.""" + +import logging +from datetime import datetime +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.database.models import User, Poll, PollResponse, PollQuestion +from app.database.crud.poll import ( + get_poll_response_by_id, + record_poll_answer, +) +from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/polls", tags=["Cabinet Polls"]) + + +# ============ Schemas ============ + +class PollOptionResponse(BaseModel): + """Poll option.""" + id: int + text: str + order: int + + +class PollQuestionResponse(BaseModel): + """Poll question with options.""" + id: int + text: str + order: int + options: List[PollOptionResponse] + + +class PollInfo(BaseModel): + """Poll info for user.""" + id: int + response_id: int + title: str + description: Optional[str] = None + total_questions: int + answered_questions: int + is_completed: bool + reward_amount: Optional[int] = None + + +class PollStartResponse(BaseModel): + """Response when starting a poll.""" + response_id: int + current_question_index: int + total_questions: int + question: PollQuestionResponse + + +class AnswerRequest(BaseModel): + """Request to answer a poll question.""" + option_id: int + + +class AnswerResponse(BaseModel): + """Response after answering.""" + success: bool + is_completed: bool + next_question: Optional[PollQuestionResponse] = None + current_question_index: Optional[int] = None + total_questions: int + reward_granted: Optional[int] = None + message: Optional[str] = None + + +# ============ Helpers ============ + +def _question_to_response(question: PollQuestion) -> PollQuestionResponse: + """Convert question model to response.""" + options = [ + PollOptionResponse( + id=opt.id, + text=opt.text, + order=opt.order, + ) + for opt in sorted(question.options, key=lambda o: o.order) + ] + return PollQuestionResponse( + id=question.id, + text=question.text, + order=question.order, + options=options, + ) + + +# ============ Routes ============ + +class PollsCountResponse(BaseModel): + """Count of available polls.""" + count: int + + +@router.get("/count", response_model=PollsCountResponse) +async def get_polls_count( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get count of polls available for the user.""" + result = await db.execute( + select(PollResponse) + .where(PollResponse.user_id == user.id) + .where(PollResponse.completed_at.is_(None)) # Only incomplete polls + ) + responses = result.scalars().all() + return PollsCountResponse(count=len(responses)) + + +@router.get("", response_model=List[PollInfo]) +async def get_available_polls( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of polls available for the user.""" + # Get user's poll responses with eager loading of relationships + result = await db.execute( + select(PollResponse) + .where(PollResponse.user_id == user.id) + .options( + selectinload(PollResponse.poll).selectinload(Poll.questions), + selectinload(PollResponse.answers), + ) + .order_by(PollResponse.created_at.desc()) + ) + responses = result.scalars().all() + + polls = [] + for response in responses: + if not response.poll: + continue + + answered_count = len(response.answers) if response.answers else 0 + total_questions = len(response.poll.questions) if response.poll.questions else 0 + + # Convert kopeks to rubles for display + reward_amount = None + if response.poll.reward_amount_kopeks: + reward_amount = response.poll.reward_amount_kopeks // 100 + + polls.append(PollInfo( + id=response.poll.id, + response_id=response.id, + title=response.poll.title, + description=response.poll.description, + total_questions=total_questions, + answered_questions=answered_count, + is_completed=response.completed_at is not None, + reward_amount=reward_amount, + )) + + return polls + + +@router.get("/{response_id}", response_model=PollInfo) +async def get_poll_details( + response_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get details of a specific poll response.""" + response = await get_poll_response_by_id(db, response_id) + + if not response or response.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Poll not found", + ) + + if not response.poll: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Poll data not available", + ) + + answered_count = len(response.answers) if response.answers else 0 + total_questions = len(response.poll.questions) if response.poll.questions else 0 + + # Convert kopeks to rubles for display + reward_amount = None + if response.poll.reward_amount_kopeks: + reward_amount = response.poll.reward_amount_kopeks // 100 + + return PollInfo( + id=response.poll.id, + response_id=response.id, + title=response.poll.title, + description=response.poll.description, + total_questions=total_questions, + answered_questions=answered_count, + is_completed=response.completed_at is not None, + reward_amount=reward_amount, + ) + + +@router.post("/{response_id}/start", response_model=PollStartResponse) +async def start_poll( + response_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Start or continue a poll.""" + response = await get_poll_response_by_id(db, response_id) + + if not response or response.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Poll not found", + ) + + if response.completed_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This poll has already been completed", + ) + + if not response.poll or not response.poll.questions: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Poll is not available", + ) + + # Mark as started if not already + if not response.started_at: + response.started_at = datetime.utcnow() + await db.commit() + + # Get next unanswered question + index, question = await get_next_question(response) + + if not question: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No questions available", + ) + + return PollStartResponse( + response_id=response.id, + current_question_index=index, + total_questions=len(response.poll.questions), + question=_question_to_response(question), + ) + + +@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse) +async def answer_question( + response_id: int, + question_id: int, + request: AnswerRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Submit answer for a poll question.""" + response = await get_poll_response_by_id(db, response_id) + + if not response or response.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Poll not found", + ) + + if response.completed_at: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This poll has already been completed", + ) + + if not response.poll: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Poll is not available", + ) + + # Find the question + question = next((q for q in response.poll.questions if q.id == question_id), None) + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Question not found", + ) + + # Validate option + option = await get_question_option(question, request.option_id) + if not option: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid answer option", + ) + + # Record the answer + await record_poll_answer( + db, + response_id=response.id, + question_id=question.id, + option_id=option.id, + ) + + # Refresh to get updated answers + try: + await db.refresh(response, attribute_names=["answers"]) + except Exception: + response = await get_poll_response_by_id(db, response_id) + if not response: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process answer", + ) + + # Get next question + index, next_question = await get_next_question(response) + total_questions = len(response.poll.questions) + + if next_question: + # More questions to answer + return AnswerResponse( + success=True, + is_completed=False, + next_question=_question_to_response(next_question), + current_question_index=index, + total_questions=total_questions, + ) + + # Poll completed + response.completed_at = datetime.utcnow() + await db.commit() + + # Award reward if any + reward_amount = await reward_user_for_poll(db, response) + + message = "Thank you for completing the poll!" + if reward_amount: + message += f" Reward of {settings.format_price(reward_amount)} has been added to your balance." + + return AnswerResponse( + success=True, + is_completed=True, + total_questions=total_questions, + reward_granted=reward_amount, + message=message, + ) diff --git a/app/cabinet/routes/promo.py b/app/cabinet/routes/promo.py new file mode 100644 index 00000000..6acb3ea2 --- /dev/null +++ b/app/cabinet/routes/promo.py @@ -0,0 +1,306 @@ +"""Promo offers routes for cabinet - personal discounts and offers.""" + +import logging +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ + +from app.database.models import User, DiscountOffer +from app.database.crud.discount_offer import ( + get_offer_by_id, + mark_offer_claimed, +) +from app.database.crud.promo_offer_template import get_promo_offer_template_by_id +from app.services.promo_offer_service import promo_offer_service +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/promo", tags=["Cabinet Promo"]) + + +# ============ Schemas ============ + +class PromoOfferInfo(BaseModel): + """Promo offer info.""" + id: int + notification_type: str + discount_percent: Optional[int] = None + effect_type: str + expires_at: datetime + is_active: bool + is_claimed: bool + claimed_at: Optional[datetime] = None + extra_data: Optional[Dict[str, Any]] = None + + +class ActiveDiscountInfo(BaseModel): + """User's active discount info.""" + discount_percent: int + source: Optional[str] = None + expires_at: Optional[datetime] = None + is_active: bool + + +class ClaimOfferRequest(BaseModel): + """Request to claim an offer.""" + offer_id: int + + +class ClaimOfferResponse(BaseModel): + """Response after claiming offer.""" + success: bool + message: str + discount_percent: Optional[int] = None + expires_at: Optional[datetime] = None + + +class PromoGroupDiscounts(BaseModel): + """User's promo group discounts.""" + group_name: Optional[str] = None + server_discount_percent: int = 0 + traffic_discount_percent: int = 0 + device_discount_percent: int = 0 + period_discounts: Dict[str, int] = {} + + +# ============ Routes ============ + +@router.get("/offers", response_model=List[PromoOfferInfo]) +async def get_promo_offers( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of available promo offers for the user.""" + now = datetime.utcnow() + + result = await db.execute( + select(DiscountOffer) + .where( + and_( + DiscountOffer.user_id == user.id, + DiscountOffer.expires_at > now, + ) + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offers = result.scalars().all() + + return [ + PromoOfferInfo( + id=offer.id, + notification_type=offer.notification_type or "", + discount_percent=offer.discount_percent, + effect_type=offer.effect_type or "percent_discount", + expires_at=offer.expires_at, + is_active=offer.is_active and offer.claimed_at is None, + is_claimed=offer.claimed_at is not None, + claimed_at=offer.claimed_at, + extra_data=offer.extra_data, + ) + for offer in offers + ] + + +@router.get("/active-discount", response_model=ActiveDiscountInfo) +async def get_active_discount( + user: User = Depends(get_current_cabinet_user), +): + """Get user's currently active discount.""" + discount_percent = user.promo_offer_discount_percent or 0 + expires_at = user.promo_offer_discount_expires_at + source = user.promo_offer_discount_source + + now = datetime.utcnow() + is_active = discount_percent > 0 and (expires_at is None or expires_at > now) + + return ActiveDiscountInfo( + discount_percent=discount_percent if is_active else 0, + source=source, + expires_at=expires_at, + is_active=is_active, + ) + + +@router.get("/group-discounts", response_model=PromoGroupDiscounts) +async def get_promo_group_discounts( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get user's promo group discounts.""" + await db.refresh(user, ["promo_groups"]) + + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None + + if not promo_group: + return PromoGroupDiscounts() + + # Get period discounts + period_discounts = {} + raw_period_discounts = getattr(promo_group, "period_discounts", None) + if isinstance(raw_period_discounts, dict): + for key, value in raw_period_discounts.items(): + try: + period_discounts[str(key)] = int(value) + except (TypeError, ValueError): + continue + + return PromoGroupDiscounts( + group_name=promo_group.name, + server_discount_percent=promo_group.server_discount_percent or 0, + traffic_discount_percent=promo_group.traffic_discount_percent or 0, + device_discount_percent=promo_group.device_discount_percent or 0, + period_discounts=period_discounts, + ) + + +@router.post("/claim", response_model=ClaimOfferResponse) +async def claim_promo_offer( + request: ClaimOfferRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Claim a promo offer.""" + offer = await get_offer_by_id(db, request.offer_id) + + if not offer or offer.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Offer not found", + ) + + now = datetime.utcnow() + + if offer.claimed_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This offer has already been claimed", + ) + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This offer has expired", + ) + + effect_type = (offer.effect_type or "percent_discount").lower() + + # Handle test access offers + if effect_type == "test_access": + await db.refresh(user, ["subscription"]) + success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( + db, + user, + offer, + ) + + if not success: + error_messages = { + "subscription_missing": "Active subscription required for this offer", + "squads_missing": "Could not determine servers for test access", + "already_connected": "These servers are already connected", + "remnawave_sync_failed": "Failed to connect servers. Please try again later", + } + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_messages.get(error_code, "Failed to activate offer"), + ) + + await mark_offer_claimed( + db, + offer, + details={ + "context": "test_access_claim", + "new_squads": newly_added, + "expires_at": expires_at.isoformat() if expires_at else None, + }, + ) + + return ClaimOfferResponse( + success=True, + message=f"Test access activated until {expires_at.strftime('%Y-%m-%d %H:%M') if expires_at else 'unlimited'}", + expires_at=expires_at, + ) + + # Handle discount offers + discount_percent = int(offer.discount_percent or 0) + if discount_percent <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid offer", + ) + + user.promo_offer_discount_percent = discount_percent + user.promo_offer_discount_source = offer.notification_type + user.updated_at = now + + # Calculate expiration + extra_data = offer.extra_data or {} + raw_duration = extra_data.get("active_discount_hours") + template_id = extra_data.get("template_id") + + if raw_duration in (None, "") and template_id: + try: + template = await get_promo_offer_template_by_id(db, int(template_id)) + except (ValueError, TypeError): + template = None + if template and template.active_discount_hours: + raw_duration = template.active_discount_hours + + try: + duration_hours = int(raw_duration) if raw_duration is not None else None + except (TypeError, ValueError): + duration_hours = None + + if duration_hours and duration_hours > 0: + discount_expires_at = now + timedelta(hours=duration_hours) + else: + discount_expires_at = None + + user.promo_offer_discount_expires_at = discount_expires_at + + await mark_offer_claimed( + db, + offer, + details={ + "context": "discount_claim", + "discount_percent": discount_percent, + "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None, + }, + ) + await db.refresh(user) + + expires_text = "" + if discount_expires_at: + expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}" + + return ClaimOfferResponse( + success=True, + message=f"Discount of {discount_percent}% activated!{expires_text}", + discount_percent=discount_percent, + expires_at=discount_expires_at, + ) + + +@router.delete("/active-discount") +async def clear_active_discount( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Clear user's active discount.""" + user.promo_offer_discount_percent = 0 + user.promo_offer_discount_source = None + user.promo_offer_discount_expires_at = None + user.updated_at = datetime.utcnow() + + await db.commit() + + return {"message": "Active discount cleared"} diff --git a/app/cabinet/routes/promocode.py b/app/cabinet/routes/promocode.py new file mode 100644 index 00000000..1562663c --- /dev/null +++ b/app/cabinet/routes/promocode.py @@ -0,0 +1,77 @@ +"""Promo code routes for cabinet.""" + +import logging +from typing import Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.services.promocode_service import PromoCodeService + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/promocode", tags=["Cabinet Promocode"]) + + +class PromocodeActivateRequest(BaseModel): + """Request to activate a promo code.""" + code: str = Field(..., min_length=1, max_length=50, description="Promo code to activate") + + +class PromocodeActivateResponse(BaseModel): + """Response after activating a promo code.""" + success: bool + message: str + balance_before: float = 0 + balance_after: float = 0 + bonus_description: str | None = None + + +@router.post("/activate", response_model=PromocodeActivateResponse) +async def activate_promocode( + request: PromocodeActivateRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Activate a promo code for the current user.""" + promocode_service = PromoCodeService() + + result = await promocode_service.activate_promocode( + db=db, + user_id=user.id, + code=request.code.strip() + ) + + if result["success"]: + balance_before_rubles = result.get("balance_before_kopeks", 0) / 100 + balance_after_rubles = result.get("balance_after_kopeks", 0) / 100 + + return PromocodeActivateResponse( + success=True, + message="Promo code activated successfully", + balance_before=balance_before_rubles, + balance_after=balance_after_rubles, + bonus_description=result.get("description"), + ) + + # Map error codes to messages + error_messages = { + "not_found": "Promo code not found", + "expired": "Promo code has expired", + "used": "Promo code has been fully used", + "already_used_by_user": "You have already used this promo code", + "user_not_found": "User not found", + "server_error": "Server error occurred", + } + + error_code = result.get("error", "server_error") + error_message = error_messages.get(error_code, "Failed to activate promo code") + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_message, + ) diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py new file mode 100644 index 00000000..02570d62 --- /dev/null +++ b/app/cabinet/routes/referral.py @@ -0,0 +1,196 @@ +"""Referral program routes for cabinet.""" + +import logging +import math +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc + +from app.database.models import User, ReferralEarning +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.referral import ( + ReferralInfoResponse, + ReferralItemResponse, + ReferralListResponse, + ReferralEarningResponse, + ReferralEarningsListResponse, + ReferralTermsResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/referral", tags=["Cabinet Referral"]) + + +@router.get("", response_model=ReferralInfoResponse) +async def get_referral_info( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get referral program info for current user.""" + # Get total referrals count + total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id) + total_result = await db.execute(total_query) + total_referrals = total_result.scalar() or 0 + + # Get active referrals (with subscription) + active_query = ( + select(func.count()) + .select_from(User) + .where(User.referred_by_id == user.id) + .where(User.has_had_paid_subscription == True) + ) + active_result = await db.execute(active_query) + active_referrals = active_result.scalar() or 0 + + # Get total earnings + earnings_query = ( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.user_id == user.id) + ) + earnings_result = await db.execute(earnings_query) + total_earnings = earnings_result.scalar() or 0 + + # Get user's commission percent + commission_percent = user.referral_commission_percent + if commission_percent is None: + commission_percent = settings.REFERRAL_COMMISSION_PERCENT + + # Build referral link + bot_username = settings.get_bot_username() or "bot" + referral_link = f"https://t.me/{bot_username}?start={user.referral_code}" + + return ReferralInfoResponse( + referral_code=user.referral_code or "", + referral_link=referral_link, + total_referrals=total_referrals, + active_referrals=active_referrals, + total_earnings_kopeks=total_earnings, + total_earnings_rubles=total_earnings / 100, + commission_percent=commission_percent, + ) + + +@router.get("/list", response_model=ReferralListResponse) +async def get_referral_list( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of invited users.""" + # Base query + query = select(User).where(User.referred_by_id == user.id) + + # Get total count + count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Paginate + offset = (page - 1) * per_page + query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page) + + result = await db.execute(query) + referrals = result.scalars().all() + + items = [ + ReferralItemResponse( + id=r.id, + username=r.username, + first_name=r.first_name, + created_at=r.created_at, + has_subscription=r.subscription is not None, + has_paid=r.has_had_paid_subscription, + ) + for r in referrals + ] + + pages = math.ceil(total / per_page) if total > 0 else 1 + + return ReferralListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.get("/earnings", response_model=ReferralEarningsListResponse) +async def get_referral_earnings( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get referral earnings history.""" + # Base query + query = select(ReferralEarning).where(ReferralEarning.user_id == user.id) + + # Get total count and sum + count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + sum_query = ( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.user_id == user.id) + ) + sum_result = await db.execute(sum_query) + total_amount = sum_result.scalar() or 0 + + # Paginate + offset = (page - 1) * per_page + query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page) + + result = await db.execute(query) + earnings = result.scalars().all() + + items = [] + for e in earnings: + # Get referral user info + referral_query = select(User).where(User.id == e.referral_id) + referral_result = await db.execute(referral_query) + referral_user = referral_result.scalar_one_or_none() + + items.append(ReferralEarningResponse( + id=e.id, + amount_kopeks=e.amount_kopeks, + amount_rubles=e.amount_kopeks / 100, + reason=e.reason or "Referral commission", + referral_username=referral_user.username if referral_user else None, + referral_first_name=referral_user.first_name if referral_user else None, + created_at=e.created_at, + )) + + pages = math.ceil(total / per_page) if total > 0 else 1 + + return ReferralEarningsListResponse( + items=items, + total=total, + total_amount_kopeks=total_amount, + total_amount_rubles=total_amount / 100, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.get("/terms", response_model=ReferralTermsResponse) +async def get_referral_terms(): + """Get referral program terms.""" + return ReferralTermsResponse( + is_enabled=settings.is_referral_program_enabled(), + commission_percent=settings.REFERRAL_COMMISSION_PERCENT, + minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS, + minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100, + first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, + first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100, + inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS, + inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100, + ) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py new file mode 100644 index 00000000..b941986c --- /dev/null +++ b/app/cabinet/routes/subscription.py @@ -0,0 +1,1029 @@ +"""Subscription management routes for cabinet.""" + +import base64 +import json +import logging +from datetime import datetime +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User, Subscription, ServerSquad +from app.database.crud.subscription import create_trial_subscription, get_subscription_by_user_id +from sqlalchemy import select +from app.config import settings, PERIOD_PRICES +from app.services.subscription_service import SubscriptionService +from app.services.subscription_purchase_service import ( + MiniAppSubscriptionPurchaseService, + PurchaseValidationError, + PurchaseBalanceError, +) + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.subscription import ( + SubscriptionResponse, + ServerInfo, + RenewalOptionResponse, + RenewalRequest, + TrafficPackageResponse, + TrafficPurchaseRequest, + DevicePurchaseRequest, + AutopayUpdateRequest, + TrialInfoResponse, + PurchaseSelectionRequest, + PurchasePreviewRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/subscription", tags=["Cabinet Subscription"]) + + +def _subscription_to_response( + subscription: Subscription, + servers: Optional[List[ServerInfo]] = None +) -> SubscriptionResponse: + """Convert Subscription model to response.""" + now = datetime.utcnow() + + # Use actual_status property for correct status (same as bot uses) + actual_status = subscription.actual_status + is_expired = actual_status == "expired" + is_active = actual_status in ("active", "trial") + + # Calculate time remaining + days_left = 0 + hours_left = 0 + minutes_left = 0 + time_left_display = "" + + if subscription.end_date and not is_expired: + time_delta = subscription.end_date - now + total_seconds = max(0, int(time_delta.total_seconds())) + + days_left = total_seconds // 86400 # 86400 seconds in a day + remaining_seconds = total_seconds % 86400 + hours_left = remaining_seconds // 3600 + minutes_left = (remaining_seconds % 3600) // 60 + + # Create human-readable display + if days_left > 0: + time_left_display = f"{days_left}d {hours_left}h" + elif hours_left > 0: + time_left_display = f"{hours_left}h {minutes_left}m" + elif minutes_left > 0: + time_left_display = f"{minutes_left}m" + else: + time_left_display = "0m" + else: + time_left_display = "0m" + + traffic_limit_gb = subscription.traffic_limit_gb or 0 + traffic_used_gb = subscription.traffic_used_gb or 0.0 + + if traffic_limit_gb > 0: + traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100) + else: + traffic_used_percent = 0 + + return SubscriptionResponse( + id=subscription.id, + status=actual_status, # Use actual_status instead of raw status + is_trial=subscription.is_trial or actual_status == "trial", + start_date=subscription.start_date, + end_date=subscription.end_date, + days_left=days_left, + hours_left=hours_left, + minutes_left=minutes_left, + time_left_display=time_left_display, + traffic_limit_gb=traffic_limit_gb, + traffic_used_gb=round(traffic_used_gb, 2), + traffic_used_percent=round(traffic_used_percent, 1), + device_limit=subscription.device_limit or 1, + connected_squads=subscription.connected_squads or [], + servers=servers or [], + autopay_enabled=subscription.autopay_enabled or False, + autopay_days_before=subscription.autopay_days_before or 3, + subscription_url=subscription.subscription_url, + is_active=is_active, + is_expired=is_expired, + ) + + +@router.get("", response_model=SubscriptionResponse) +async def get_subscription( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get current user's subscription details.""" + # Reload user from current session to get fresh data + # (user object is from different session in get_current_cabinet_user) + from app.database.crud.user import get_user_by_id + fresh_user = await get_user_by_id(db, user.id) + + if not fresh_user or not fresh_user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + # Fetch server names for connected squads + servers: List[ServerInfo] = [] + connected_squads = fresh_user.subscription.connected_squads or [] + if connected_squads: + result = await db.execute( + select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)) + ) + server_squads = result.scalars().all() + servers = [ + ServerInfo( + uuid=sq.squad_uuid, + name=sq.display_name, + country_code=sq.country_code + ) + for sq in server_squads + ] + + return _subscription_to_response(fresh_user.subscription, servers) + + +@router.get("/renewal-options", response_model=List[RenewalOptionResponse]) +async def get_renewal_options( + user: User = Depends(get_current_cabinet_user), +): + """Get available subscription renewal options with prices.""" + periods = settings.get_available_renewal_periods() + options = [] + + for period in periods: + price_kopeks = PERIOD_PRICES.get(period, 0) + if price_kopeks <= 0: + continue + + # Apply user's discount if any + discount_percent = 0 + if hasattr(user, "get_promo_discount"): + discount_percent = user.get_promo_discount("period", period) + + if discount_percent > 0: + original_price = price_kopeks + price_kopeks = int(price_kopeks * (100 - discount_percent) / 100) + else: + original_price = None + + options.append(RenewalOptionResponse( + period_days=period, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + discount_percent=discount_percent, + original_price_kopeks=original_price, + )) + + return options + + +@router.post("/renew") +async def renew_subscription( + request: RenewalRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Renew subscription (pay from balance).""" + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + # Get price for requested period + price_kopeks = PERIOD_PRICES.get(request.period_days, 0) + if price_kopeks <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid renewal period", + ) + + # Apply discount + discount_percent = 0 + if hasattr(user, "get_promo_discount"): + discount_percent = user.get_promo_discount("period", request.period_days) + + if discount_percent > 0: + price_kopeks = int(price_kopeks * (100 - discount_percent) / 100) + + # Check balance + if user.balance_kopeks < price_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", + ) + + # Deduct balance and extend subscription + user.balance_kopeks -= price_kopeks + + # Extend from end_date or now if expired + now = datetime.utcnow() + if user.subscription.end_date and user.subscription.end_date > now: + from datetime import timedelta + user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days) + else: + from datetime import timedelta + user.subscription.end_date = now + timedelta(days=request.period_days) + user.subscription.start_date = now + + user.subscription.status = "active" + user.subscription.is_trial = False + + await db.commit() + + return { + "message": "Subscription renewed successfully", + "new_end_date": user.subscription.end_date.isoformat(), + "amount_paid_kopeks": price_kopeks, + } + + +@router.get("/traffic-packages", response_model=List[TrafficPackageResponse]) +async def get_traffic_packages(): + """Get available traffic packages.""" + packages = settings.get_traffic_packages() + result = [] + + for pkg in packages: + if not pkg.get("enabled", True): + continue + + result.append(TrafficPackageResponse( + gb=pkg["gb"], + price_kopeks=pkg["price"], + price_rubles=pkg["price"] / 100, + is_unlimited=pkg["gb"] == 0, + )) + + return result + + +@router.post("/traffic") +async def purchase_traffic( + request: TrafficPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Purchase additional traffic.""" + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + # Find matching package + packages = settings.get_traffic_packages() + matching_pkg = next( + (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), + None + ) + + if not matching_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid traffic package", + ) + + price_kopeks = matching_pkg["price"] + + # Check balance + if user.balance_kopeks < price_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Insufficient balance", + ) + + # Deduct balance and add traffic + user.balance_kopeks -= price_kopeks + + if request.gb == 0: + # Unlimited traffic + user.subscription.traffic_limit = 0 # 0 means unlimited + else: + # Add GB to current limit + current_limit = user.subscription.traffic_limit or 0 + additional_bytes = request.gb * (1024 ** 3) + user.subscription.traffic_limit = current_limit + additional_bytes + + await db.commit() + + return { + "message": "Traffic purchased successfully", + "gb_added": request.gb, + "amount_paid_kopeks": price_kopeks, + } + + +@router.post("/devices") +async def purchase_devices( + request: DevicePurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Purchase additional device slots.""" + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + price_per_device = settings.PRICE_PER_DEVICE + total_price = price_per_device * request.devices + + # Check balance + if user.balance_kopeks < total_price: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Insufficient balance", + ) + + # Check max devices limit + current_devices = user.subscription.device_limit or 1 + new_devices = current_devices + request.devices + max_devices = settings.MAX_DEVICES_LIMIT + + if new_devices > max_devices: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum device limit is {max_devices}", + ) + + # Deduct balance and add devices + user.balance_kopeks -= total_price + user.subscription.device_limit = new_devices + + await db.commit() + + return { + "message": "Devices added successfully", + "devices_added": request.devices, + "new_device_limit": new_devices, + "amount_paid_kopeks": total_price, + } + + +@router.patch("/autopay") +async def update_autopay( + request: AutopayUpdateRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update autopay settings.""" + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + user.subscription.autopay_enabled = request.enabled + + if request.days_before is not None: + user.subscription.autopay_days_before = request.days_before + + await db.commit() + + return { + "message": "Autopay settings updated", + "autopay_enabled": user.subscription.autopay_enabled, + "autopay_days_before": user.subscription.autopay_days_before, + } + + +@router.get("/trial", response_model=TrialInfoResponse) +async def get_trial_info( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get trial subscription info and availability.""" + await db.refresh(user, ["subscription"]) + + duration_days = settings.TRIAL_DURATION_DAYS + traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB + device_limit = settings.TRIAL_DEVICE_LIMIT + requires_payment = bool(settings.TRIAL_PAYMENT_ENABLED) + price_kopeks = settings.TRIAL_ACTIVATION_PRICE if requires_payment else 0 + + # Check if user already has an active subscription + if user.subscription: + now = datetime.utcnow() + is_active = ( + user.subscription.status == "active" + and user.subscription.end_date + and user.subscription.end_date > now + ) + if is_active: + return TrialInfoResponse( + is_available=False, + duration_days=duration_days, + traffic_limit_gb=traffic_limit_gb, + device_limit=device_limit, + requires_payment=requires_payment, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + reason_unavailable="You already have an active subscription", + ) + + # Check if user already used trial + if user.subscription.is_trial or user.has_had_paid_subscription: + return TrialInfoResponse( + is_available=False, + duration_days=duration_days, + traffic_limit_gb=traffic_limit_gb, + device_limit=device_limit, + requires_payment=requires_payment, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + reason_unavailable="Trial already used", + ) + + return TrialInfoResponse( + is_available=True, + duration_days=duration_days, + traffic_limit_gb=traffic_limit_gb, + device_limit=device_limit, + requires_payment=requires_payment, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + ) + + +@router.post("/trial", response_model=SubscriptionResponse) +async def activate_trial( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Activate trial subscription.""" + await db.refresh(user, ["subscription"]) + + # Check if user already has an active subscription + if user.subscription: + now = datetime.utcnow() + is_active = ( + user.subscription.status == "active" + and user.subscription.end_date + and user.subscription.end_date > now + ) + if is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You already have an active subscription", + ) + + # Check if user already used trial + if user.subscription.is_trial or user.has_had_paid_subscription: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Trial already used", + ) + + # Check if trial requires payment + requires_payment = bool(settings.TRIAL_PAYMENT_ENABLED) + if requires_payment: + price_kopeks = settings.TRIAL_ACTIVATION_PRICE + if user.balance_kopeks < price_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB", + ) + user.balance_kopeks -= price_kopeks + logger.info(f"User {user.id} paid {price_kopeks} kopeks for trial activation") + + # Create trial subscription + subscription = await create_trial_subscription( + db=db, + user_id=user.id, + duration_days=settings.TRIAL_DURATION_DAYS, + traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB, + device_limit=settings.TRIAL_DEVICE_LIMIT, + ) + + logger.info(f"Trial subscription activated for user {user.id}") + + # Create RemnaWave user + try: + subscription_service = SubscriptionService() + if subscription_service.is_configured: + await subscription_service.create_remnawave_user(db, subscription) + await db.refresh(subscription) + except Exception as e: + logger.error(f"Failed to create RemnaWave user for trial: {e}") + + return _subscription_to_response(subscription) + + +# ============ Full Purchase Flow (like MiniApp) ============ + +purchase_service = MiniAppSubscriptionPurchaseService() + + +@router.get("/purchase-options") +async def get_purchase_options( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get all subscription purchase options (periods, servers, traffic, devices).""" + try: + context = await purchase_service.build_options(db, user) + return context.payload + except PurchaseValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + logger.error(f"Failed to build purchase options for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load purchase options", + ) + + +@router.post("/purchase-preview") +async def preview_purchase( + request: PurchasePreviewRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Calculate and preview the total price for selected options.""" + try: + context = await purchase_service.build_options(db, user) + + # Convert request to dict for parsing + selection_dict = { + "period_id": request.selection.period_id, + "period_days": request.selection.period_days, + "traffic_value": request.selection.traffic_value, + "servers": request.selection.servers, + "devices": request.selection.devices, + } + + selection = purchase_service.parse_selection(context, selection_dict) + pricing = await purchase_service.calculate_pricing(db, context, selection) + preview = purchase_service.build_preview_payload(context, pricing) + + return preview + + except PurchaseValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + logger.error(f"Failed to calculate purchase preview for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to calculate price", + ) + + +@router.post("/purchase") +async def submit_purchase( + request: PurchasePreviewRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Submit subscription purchase (deduct from balance).""" + try: + context = await purchase_service.build_options(db, user) + + # Convert request to dict for parsing + selection_dict = { + "period_id": request.selection.period_id, + "period_days": request.selection.period_days, + "traffic_value": request.selection.traffic_value, + "servers": request.selection.servers, + "devices": request.selection.devices, + } + + selection = purchase_service.parse_selection(context, selection_dict) + pricing = await purchase_service.calculate_pricing(db, context, selection) + result = await purchase_service.submit_purchase(db, context, pricing) + + subscription = result["subscription"] + + return { + "success": True, + "message": result["message"], + "subscription": _subscription_to_response(subscription), + "was_trial_conversion": result.get("was_trial_conversion", False), + } + + except PurchaseValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except PurchaseBalanceError as e: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=str(e), + ) + except Exception as e: + logger.error(f"Failed to submit purchase for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process purchase", + ) + + +# ============ App Config for Connection ============ + +def _load_app_config() -> Dict[str, Any]: + """Load app-config.json file.""" + try: + config_path = settings.get_app_config_path() + with open(config_path, 'r', encoding='utf-8') as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception as e: + logger.error(f"Failed to load app-config.json: {e}") + return {} + + +def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]: + """Create deep link for app with subscription URL.""" + if not subscription_url or not isinstance(app, dict): + return None + + scheme = str(app.get("urlScheme", "")).strip() + if not scheme: + return None + + payload = subscription_url + + if app.get("isNeedBase64Encoding"): + try: + payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8") + except Exception as e: + logger.warning(f"Failed to encode subscription URL to base64: {e}") + payload = subscription_url + + return f"{scheme}{payload}" + + +# ============ Countries Management ============ + +@router.get("/countries") +async def get_available_countries( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get available countries/servers for the user.""" + from app.database.crud.server_squad import get_available_server_squads + + await db.refresh(user, ["subscription"]) + + promo_group_id = user.promo_group_id + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) + + connected_squads = [] + if user.subscription: + connected_squads = user.subscription.connected_squads or [] + + countries = [] + for server in available_servers: + countries.append({ + "uuid": server.squad_uuid, + "name": server.display_name, + "country_code": server.country_code, + "price_kopeks": server.price_kopeks, + "price_rubles": server.price_kopeks / 100, + "is_available": server.is_available and not server.is_full, + "is_connected": server.squad_uuid in connected_squads, + "is_trial_eligible": server.is_trial_eligible, + }) + + return { + "countries": countries, + "connected_count": len(connected_squads), + "has_subscription": user.subscription is not None, + } + + +@router.post("/countries") +async def update_countries( + request: Dict[str, Any], + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Update subscription countries/servers.""" + from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.subscription import add_subscription_servers + from app.database.crud.transaction import create_transaction + from app.database.crud.user import subtract_user_balance + from app.database.models import TransactionType + from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if user.subscription.is_trial: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Country management is not available for trial subscriptions", + ) + + selected_countries = request.get("countries", []) + if not selected_countries: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one country must be selected", + ) + + current_countries = user.subscription.connected_squads or [] + promo_group_id = user.promo_group_id + + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) + allowed_country_ids = {server.squad_uuid for server in available_servers} + + # Validate selected countries + for country_uuid in selected_countries: + if country_uuid not in allowed_country_ids and country_uuid not in current_countries: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Country {country_uuid} is not available", + ) + + added = [c for c in selected_countries if c not in current_countries] + removed = [c for c in current_countries if c not in selected_countries] + + if not added and not removed: + return { + "message": "No changes detected", + "connected_squads": current_countries, + } + + # Calculate cost for added servers + total_cost = 0 + added_names = [] + removed_names = [] + + servers_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None + if promo_group: + servers_discount_percent = promo_group.get_discount_percent("servers", None) + + added_server_prices = [] + + for server in available_servers: + if server.squad_uuid in added: + server_price_per_month = server.price_kopeks + if servers_discount_percent > 0: + discounted_per_month, _ = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + else: + discounted_per_month = server_price_per_month + + charged_price, charged_months = calculate_prorated_price( + discounted_per_month, + user.subscription.end_date, + ) + + total_cost += charged_price + added_names.append(server.display_name) + added_server_prices.append(charged_price) + + if server.squad_uuid in removed: + removed_names.append(server.display_name) + + # Check balance + if total_cost > 0 and user.balance_kopeks < total_cost: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", + ) + + # Deduct balance and update subscription + if added and total_cost > 0: + success = await subtract_user_balance( + db, user, total_cost, + f"Adding countries: {', '.join(added_names)}" + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=total_cost, + description=f"Adding countries to subscription: {', '.join(added_names)}" + ) + + # Add servers to subscription + if added: + added_server_ids = await get_server_ids_by_uuids(db, added) + if added_server_ids: + await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices) + await add_user_to_servers(db, added_server_ids) + + # Update connected squads + user.subscription.connected_squads = selected_countries + user.subscription.updated_at = datetime.utcnow() + await db.commit() + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception as e: + logger.error(f"Failed to sync countries with RemnaWave: {e}") + + await db.refresh(user.subscription) + + return { + "message": "Countries updated successfully", + "added": added_names, + "removed": removed_names, + "amount_paid_kopeks": total_cost, + "connected_squads": user.subscription.connected_squads, + } + + +# ============ Connection Link ============ + +@router.get("/connection-link") +async def get_connection_link( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get subscription connection link and instructions.""" + from app.utils.subscription_utils import ( + get_display_subscription_link, + get_happ_cryptolink_redirect_link, + convert_subscription_link_to_happ_scheme, + ) + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + subscription_url = user.subscription.subscription_url + if not subscription_url: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Subscription link not yet generated", + ) + + display_link = get_display_subscription_link(user.subscription) + happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None + happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None + + connect_mode = settings.CONNECT_BUTTON_MODE + hide_subscription_link = settings.should_hide_subscription_link() + + return { + "subscription_url": subscription_url if not hide_subscription_link else None, + "display_link": display_link if not hide_subscription_link else None, + "happ_redirect_link": happ_redirect, + "happ_scheme_link": happ_scheme_link, + "connect_mode": connect_mode, + "hide_link": hide_subscription_link, + "instructions": { + "steps": [ + "Copy the subscription link", + "Open your VPN application", + "Find 'Add subscription' or 'Import' option", + "Paste the copied link", + ] + } + } + + +# ============ hApp Downloads ============ + +@router.get("/happ-downloads") +async def get_happ_downloads( + user: User = Depends(get_current_cabinet_user), +) -> Dict[str, Any]: + """Get hApp download links for different platforms.""" + platforms = { + "ios": { + "name": "iOS (iPhone/iPad)", + "icon": "🍎", + "link": settings.get_happ_download_link("ios"), + }, + "android": { + "name": "Android", + "icon": "🤖", + "link": settings.get_happ_download_link("android"), + }, + "macos": { + "name": "macOS", + "icon": "🖥️", + "link": settings.get_happ_download_link("macos"), + }, + "windows": { + "name": "Windows", + "icon": "💻", + "link": settings.get_happ_download_link("windows"), + }, + } + + # Filter out platforms without links + available_platforms = { + k: v for k, v in platforms.items() if v["link"] + } + + return { + "platforms": available_platforms, + "happ_enabled": bool(available_platforms), + } + + +@router.get("/app-config") +async def get_app_config( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get app configuration for connection with deep links.""" + await db.refresh(user, ["subscription"]) + + subscription_url = None + if user.subscription: + subscription_url = user.subscription.subscription_url + + config = _load_app_config() + platforms_raw = config.get("platforms", {}) + + if not isinstance(platforms_raw, dict): + platforms_raw = {} + + # Build response with deep links + platforms = {} + for platform_key, apps in platforms_raw.items(): + if not isinstance(apps, list): + continue + + platform_apps = [] + for app in apps: + if not isinstance(app, dict): + continue + + app_data = { + "id": app.get("id"), + "name": app.get("name"), + "isFeatured": app.get("isFeatured", False), + "installationStep": app.get("installationStep"), + "addSubscriptionStep": app.get("addSubscriptionStep"), + "connectAndUseStep": app.get("connectAndUseStep"), + "additionalBeforeAddSubscriptionStep": app.get("additionalBeforeAddSubscriptionStep"), + "additionalAfterAddSubscriptionStep": app.get("additionalAfterAddSubscriptionStep"), + } + + # Add deep link if subscription exists + if subscription_url: + app_data["deepLink"] = _create_deep_link(app, subscription_url) + + platform_apps.append(app_data) + + if platform_apps: + platforms[platform_key] = platform_apps + + # Platform display names for UI + platform_names = { + "ios": {"ru": "iPhone/iPad", "en": "iPhone/iPad"}, + "android": {"ru": "Android", "en": "Android"}, + "macos": {"ru": "macOS", "en": "macOS"}, + "windows": {"ru": "Windows", "en": "Windows"}, + "linux": {"ru": "Linux", "en": "Linux"}, + "androidTV": {"ru": "Android TV", "en": "Android TV"}, + "appleTV": {"ru": "Apple TV", "en": "Apple TV"}, + } + + return { + "platforms": platforms, + "platformNames": platform_names, + "hasSubscription": bool(subscription_url), + "subscriptionUrl": subscription_url, + "branding": config.get("config", {}).get("branding", {}), + } diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py new file mode 100644 index 00000000..2b2ebe86 --- /dev/null +++ b/app/cabinet/routes/tickets.py @@ -0,0 +1,264 @@ +"""Support tickets routes for cabinet.""" + +import logging +import math +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc +from sqlalchemy.orm import selectinload + +from app.database.models import User, Ticket, TicketMessage +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.tickets import ( + TicketResponse, + TicketDetailResponse, + TicketListResponse, + TicketMessageResponse, + TicketCreateRequest, + TicketMessageCreateRequest, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"]) + + +def _message_to_response(message: TicketMessage) -> TicketMessageResponse: + """Convert TicketMessage to response.""" + return TicketMessageResponse( + id=message.id, + message_text=message.message_text or "", + is_from_admin=message.is_from_admin, + has_media=bool(message.media_file_id), + media_type=message.media_type, + media_caption=message.media_caption, + created_at=message.created_at, + ) + + +def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse: + """Convert Ticket to response.""" + last_message = None + messages_count = len(ticket.messages) if ticket.messages else 0 + + if include_last_message and ticket.messages: + last_msg = max(ticket.messages, key=lambda m: m.created_at) + last_message = _message_to_response(last_msg) + + return TicketResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + messages_count=messages_count, + last_message=last_message, + ) + + +@router.get("", response_model=TicketListResponse) +async def get_tickets( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(20, ge=1, le=100, description="Items per page"), + status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get user's support tickets.""" + # Check if tickets are enabled + if not settings.is_support_tickets_enabled(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Support tickets are disabled", + ) + + # Base query + query = ( + select(Ticket) + .where(Ticket.user_id == user.id) + .options(selectinload(Ticket.messages)) + ) + + # Filter by status + if status_filter: + query = query.where(Ticket.status == status_filter) + + # Get total count + count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id) + if status_filter: + count_query = count_query.where(Ticket.status == status_filter) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # Paginate + offset = (page - 1) * per_page + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page) + + result = await db.execute(query) + tickets = result.scalars().all() + + items = [_ticket_to_response(t) for t in tickets] + pages = math.ceil(total / per_page) if total > 0 else 1 + + return TicketListResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +@router.post("", response_model=TicketDetailResponse) +async def create_ticket( + request: TicketCreateRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Create a new support ticket.""" + # Check if tickets are enabled + if not settings.is_support_tickets_enabled(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Support tickets are disabled", + ) + + # Create ticket + ticket = Ticket( + user_id=user.id, + title=request.title, + status="open", + priority="normal", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(ticket) + await db.flush() + + # Create initial message + message = TicketMessage( + ticket_id=ticket.id, + user_id=user.id, + message_text=request.message, + is_from_admin=False, + created_at=datetime.utcnow(), + ) + db.add(message) + await db.commit() + + # Refresh to get relationships + await db.refresh(ticket, ["messages"]) + + messages = [_message_to_response(m) for m in ticket.messages] + + return TicketDetailResponse( + id=ticket.id, + title=ticket.title, + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at, + closed_at=ticket.closed_at, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + messages=messages, + ) + + +@router.get("/{ticket_id}", response_model=TicketDetailResponse) +async def get_ticket( + ticket_id: int, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket with all messages.""" + query = ( + select(Ticket) + .where(Ticket.id == ticket_id, Ticket.user_id == user.id) + .options(selectinload(Ticket.messages)) + ) + + result = await db.execute(query) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + messages = sorted(ticket.messages or [], key=lambda m: m.created_at) + messages_response = [_message_to_response(m) for m in messages] + + return TicketDetailResponse( + id=ticket.id, + title=ticket.title or f"Ticket #{ticket.id}", + status=ticket.status, + priority=ticket.priority or "normal", + created_at=ticket.created_at, + updated_at=ticket.updated_at or ticket.created_at, + closed_at=ticket.closed_at, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + messages=messages_response, + ) + + +@router.post("/{ticket_id}/messages", response_model=TicketMessageResponse) +async def add_ticket_message( + ticket_id: int, + request: TicketMessageCreateRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Add message to existing ticket.""" + # Get ticket + query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id) + result = await db.execute(query) + ticket = result.scalar_one_or_none() + + if not ticket: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ticket not found", + ) + + # Check if ticket is closed + if ticket.status == "closed": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot add message to closed ticket", + ) + + # Check if replies are blocked + if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_blocked: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Replies to this ticket are blocked", + ) + + # Create message + message = TicketMessage( + ticket_id=ticket.id, + user_id=user.id, + message_text=request.message, + is_from_admin=False, + created_at=datetime.utcnow(), + ) + db.add(message) + + # Update ticket status and timestamp + if ticket.status == "answered": + ticket.status = "pending" + ticket.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(message) + + return _message_to_response(message) diff --git a/app/cabinet/schemas/__init__.py b/app/cabinet/schemas/__init__.py new file mode 100644 index 00000000..8bde9f9c --- /dev/null +++ b/app/cabinet/schemas/__init__.py @@ -0,0 +1,86 @@ +"""Cabinet Pydantic schemas.""" + +from .auth import ( + TelegramAuthRequest, + TelegramWidgetAuthRequest, + EmailRegisterRequest, + EmailVerifyRequest, + EmailLoginRequest, + RefreshTokenRequest, + PasswordForgotRequest, + PasswordResetRequest, + TokenResponse, + UserResponse, + AuthResponse, +) +from .subscription import ( + SubscriptionResponse, + RenewalOptionResponse, + RenewalRequest, + TrafficPackageResponse, + TrafficPurchaseRequest, + DevicePurchaseRequest, + AutopayUpdateRequest, +) +from .balance import ( + BalanceResponse, + TransactionResponse, + TransactionListResponse, + PaymentMethodResponse, + TopUpRequest, + TopUpResponse, +) +from .referral import ( + ReferralInfoResponse, + ReferralListResponse, + ReferralEarningResponse, + ReferralTermsResponse, +) +from .tickets import ( + TicketResponse, + TicketListResponse, + TicketMessageResponse, + TicketCreateRequest, + TicketMessageCreateRequest, +) + +__all__ = [ + # Auth + "TelegramAuthRequest", + "TelegramWidgetAuthRequest", + "EmailRegisterRequest", + "EmailVerifyRequest", + "EmailLoginRequest", + "RefreshTokenRequest", + "PasswordForgotRequest", + "PasswordResetRequest", + "TokenResponse", + "UserResponse", + "AuthResponse", + # Subscription + "SubscriptionResponse", + "RenewalOptionResponse", + "RenewalRequest", + "TrafficPackageResponse", + "TrafficPurchaseRequest", + "DevicePurchaseRequest", + "AutopayUpdateRequest", + # Balance + "BalanceResponse", + "TransactionResponse", + "TransactionListResponse", + "PaymentMethodResponse", + "TopUpRequest", + "TopUpResponse", + # Referral + "ReferralInfoResponse", + "ReferralListResponse", + "ReferralEarningResponse", + "ReferralTermsResponse", + # Tickets + "TicketResponse", + "TicketListResponse", + "TicketMessageResponse", + "TicketCreateRequest", + "TicketMessageCreateRequest", +] diff --git a/app/cabinet/schemas/auth.py b/app/cabinet/schemas/auth.py new file mode 100644 index 00000000..02cfe267 --- /dev/null +++ b/app/cabinet/schemas/auth.py @@ -0,0 +1,90 @@ +"""Authentication schemas for cabinet.""" + +from datetime import datetime +from typing import Optional, Dict, Any +from pydantic import BaseModel, EmailStr, Field + + +class TelegramAuthRequest(BaseModel): + """Request for Telegram WebApp initData authentication.""" + init_data: str = Field(..., description="Telegram WebApp initData string") + + +class TelegramWidgetAuthRequest(BaseModel): + """Request for Telegram Login Widget authentication.""" + id: int = Field(..., description="Telegram user ID") + first_name: str = Field(..., description="User's first name") + last_name: Optional[str] = Field(None, description="User's last name") + username: Optional[str] = Field(None, description="User's username") + photo_url: Optional[str] = Field(None, description="User's photo URL") + auth_date: int = Field(..., description="Unix timestamp of authentication") + hash: str = Field(..., description="Authentication hash") + + +class EmailRegisterRequest(BaseModel): + """Request to register/link email to existing Telegram account.""" + email: EmailStr = Field(..., description="Email address") + password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)") + + +class EmailVerifyRequest(BaseModel): + """Request to verify email with token.""" + token: str = Field(..., description="Email verification token") + + +class EmailLoginRequest(BaseModel): + """Request to login with email and password.""" + email: EmailStr = Field(..., description="Email address") + password: str = Field(..., description="Password") + + +class RefreshTokenRequest(BaseModel): + """Request to refresh access token.""" + refresh_token: str = Field(..., description="Refresh token") + + +class PasswordForgotRequest(BaseModel): + """Request to initiate password reset.""" + email: EmailStr = Field(..., description="Email address") + + +class PasswordResetRequest(BaseModel): + """Request to reset password with token.""" + token: str = Field(..., description="Password reset token") + password: str = Field(..., min_length=8, max_length=128, description="New password (min 8 chars)") + + +class TokenResponse(BaseModel): + """Token pair response.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = Field(..., description="Access token expiration in seconds") + + +class UserResponse(BaseModel): + """User data response.""" + id: int + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + email_verified: bool = False + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + referral_code: Optional[str] = None + language: str = "ru" + created_at: datetime + + class Config: + from_attributes = True + + +class AuthResponse(BaseModel): + """Full authentication response with tokens and user.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse diff --git a/app/cabinet/schemas/balance.py b/app/cabinet/schemas/balance.py new file mode 100644 index 00000000..1f3ce610 --- /dev/null +++ b/app/cabinet/schemas/balance.py @@ -0,0 +1,62 @@ +"""Balance and payment schemas for cabinet.""" + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class BalanceResponse(BaseModel): + """User balance data.""" + balance_kopeks: int + balance_rubles: float + + +class TransactionResponse(BaseModel): + """Transaction history item.""" + id: int + type: str + amount_kopeks: int + amount_rubles: float + description: Optional[str] = None + payment_method: Optional[str] = None + is_completed: bool + created_at: datetime + completed_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TransactionListResponse(BaseModel): + """Paginated transaction list.""" + items: List[TransactionResponse] + total: int + page: int + per_page: int + pages: int + + +class PaymentMethodResponse(BaseModel): + """Available payment method.""" + id: str + name: str + description: Optional[str] = None + min_amount_kopeks: int + max_amount_kopeks: int + is_available: bool = True + + +class TopUpRequest(BaseModel): + """Request to create payment for balance top-up.""" + amount_kopeks: int = Field(..., ge=1000, description="Amount in kopeks (min 10 rubles)") + payment_method: str = Field(..., description="Payment method ID") + + +class TopUpResponse(BaseModel): + """Response with payment info.""" + payment_id: str + payment_url: str + amount_kopeks: int + amount_rubles: float + status: str + expires_at: Optional[datetime] = None diff --git a/app/cabinet/schemas/referral.py b/app/cabinet/schemas/referral.py new file mode 100644 index 00000000..a6bf3609 --- /dev/null +++ b/app/cabinet/schemas/referral.py @@ -0,0 +1,72 @@ +"""Referral program schemas for cabinet.""" + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel + + +class ReferralInfoResponse(BaseModel): + """Referral program info for current user.""" + referral_code: str + referral_link: str + total_referrals: int + active_referrals: int + total_earnings_kopeks: int + total_earnings_rubles: float + commission_percent: int + + +class ReferralItemResponse(BaseModel): + """Single referral info.""" + id: int + username: Optional[str] = None + first_name: Optional[str] = None + created_at: datetime + has_subscription: bool + has_paid: bool + + +class ReferralListResponse(BaseModel): + """Paginated referral list.""" + items: List[ReferralItemResponse] + total: int + page: int + per_page: int + pages: int + + +class ReferralEarningResponse(BaseModel): + """Referral earning history item.""" + id: int + amount_kopeks: int + amount_rubles: float + reason: str + referral_username: Optional[str] = None + referral_first_name: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class ReferralEarningsListResponse(BaseModel): + """Paginated referral earnings list.""" + items: List[ReferralEarningResponse] + total: int + total_amount_kopeks: int + total_amount_rubles: float + page: int + per_page: int + pages: int + + +class ReferralTermsResponse(BaseModel): + """Referral program terms.""" + is_enabled: bool + commission_percent: int + minimum_topup_kopeks: int + minimum_topup_rubles: float + first_topup_bonus_kopeks: int + first_topup_bonus_rubles: float + inviter_bonus_kopeks: int + inviter_bonus_rubles: float diff --git a/app/cabinet/schemas/subscription.py b/app/cabinet/schemas/subscription.py new file mode 100644 index 00000000..4ede8761 --- /dev/null +++ b/app/cabinet/schemas/subscription.py @@ -0,0 +1,105 @@ +"""Subscription schemas for cabinet.""" + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class ServerInfo(BaseModel): + """Server info for display.""" + uuid: str + name: str + country_code: Optional[str] = None + + +class SubscriptionResponse(BaseModel): + """User subscription data.""" + id: int + status: str + is_trial: bool + start_date: datetime + end_date: datetime + days_left: int + hours_left: int = 0 + minutes_left: int = 0 + time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м" + traffic_limit_gb: int + traffic_used_gb: float + traffic_used_percent: float + device_limit: int + connected_squads: List[str] = [] + servers: List[ServerInfo] = [] # Server display info + autopay_enabled: bool + autopay_days_before: int + subscription_url: Optional[str] = None + is_active: bool + is_expired: bool + + class Config: + from_attributes = True + + +class RenewalOptionResponse(BaseModel): + """Available subscription renewal option.""" + period_days: int + price_kopeks: int + price_rubles: float + discount_percent: int = 0 + original_price_kopeks: Optional[int] = None + + +class RenewalRequest(BaseModel): + """Request to renew subscription.""" + period_days: int = Field(..., description="Renewal period in days") + + +class TrafficPackageResponse(BaseModel): + """Available traffic package.""" + gb: int + price_kopeks: int + price_rubles: float + is_unlimited: bool = False + + +class TrafficPurchaseRequest(BaseModel): + """Request to purchase additional traffic.""" + gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)") + + +class DevicePurchaseRequest(BaseModel): + """Request to purchase additional device slots.""" + devices: int = Field(..., ge=1, description="Number of additional devices") + + +class AutopayUpdateRequest(BaseModel): + """Request to update autopay settings.""" + enabled: bool + days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge") + + +class TrialInfoResponse(BaseModel): + """Trial subscription info.""" + is_available: bool + duration_days: int + traffic_limit_gb: int + device_limit: int + requires_payment: bool = False + price_kopeks: int = 0 + price_rubles: float = 0.0 + reason_unavailable: Optional[str] = None + + +# ============ Purchase Options Schemas ============ + +class PurchaseSelectionRequest(BaseModel): + """User's selection for subscription purchase.""" + period_id: Optional[str] = Field(None, description="Period ID like 'days:30'") + period_days: Optional[int] = Field(None, description="Period in days") + traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)") + servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs") + devices: Optional[int] = Field(None, description="Device limit") + + +class PurchasePreviewRequest(BaseModel): + """Request to preview purchase pricing.""" + selection: PurchaseSelectionRequest diff --git a/app/cabinet/schemas/tickets.py b/app/cabinet/schemas/tickets.py new file mode 100644 index 00000000..a0388935 --- /dev/null +++ b/app/cabinet/schemas/tickets.py @@ -0,0 +1,71 @@ +"""Support tickets schemas for cabinet.""" + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class TicketMessageResponse(BaseModel): + """Ticket message data.""" + id: int + message_text: str + is_from_admin: bool + has_media: bool = False + media_type: Optional[str] = None + media_caption: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class TicketResponse(BaseModel): + """Ticket data.""" + id: int + title: str + status: str + priority: str + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] = None + messages_count: int = 0 + last_message: Optional[TicketMessageResponse] = None + + class Config: + from_attributes = True + + +class TicketDetailResponse(BaseModel): + """Ticket with all messages.""" + id: int + title: str + status: str + priority: str + created_at: datetime + updated_at: datetime + closed_at: Optional[datetime] = None + is_reply_blocked: bool = False + messages: List[TicketMessageResponse] = [] + + class Config: + from_attributes = True + + +class TicketListResponse(BaseModel): + """Paginated ticket list.""" + items: List[TicketResponse] + total: int + page: int + per_page: int + pages: int + + +class TicketCreateRequest(BaseModel): + """Request to create a new ticket.""" + title: str = Field(..., min_length=3, max_length=255, description="Ticket title") + message: str = Field(..., min_length=10, max_length=4000, description="Initial message") + + +class TicketMessageCreateRequest(BaseModel): + """Request to add message to ticket.""" + message: str = Field(..., min_length=1, max_length=4000, description="Message text") diff --git a/app/cabinet/services/__init__.py b/app/cabinet/services/__init__.py new file mode 100644 index 00000000..9153fe9e --- /dev/null +++ b/app/cabinet/services/__init__.py @@ -0,0 +1,5 @@ +"""Cabinet services.""" + +from .email_service import EmailService, email_service + +__all__ = ["EmailService", "email_service"] diff --git a/app/cabinet/services/email_service.py b/app/cabinet/services/email_service.py new file mode 100644 index 00000000..2a1a4571 --- /dev/null +++ b/app/cabinet/services/email_service.py @@ -0,0 +1,225 @@ +"""Email service for sending verification and password reset emails.""" + +import logging +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails via SMTP.""" + + def __init__(self): + self.host = settings.SMTP_HOST + self.port = settings.SMTP_PORT + self.user = settings.SMTP_USER + self.password = settings.SMTP_PASSWORD + self.from_email = settings.get_smtp_from_email() + self.from_name = settings.SMTP_FROM_NAME + self.use_tls = settings.SMTP_USE_TLS + + def is_configured(self) -> bool: + """Check if SMTP is properly configured.""" + return settings.is_smtp_configured() + + def _get_smtp_connection(self) -> smtplib.SMTP: + """Create and return SMTP connection.""" + if self.use_tls: + smtp = smtplib.SMTP(self.host, self.port) + smtp.starttls() + else: + smtp = smtplib.SMTP(self.host, self.port) + + if self.user and self.password: + smtp.login(self.user, self.password) + + return smtp + + def send_email( + self, + to_email: str, + subject: str, + body_html: str, + body_text: Optional[str] = None, + ) -> bool: + """ + Send an email. + + Args: + to_email: Recipient email address + subject: Email subject + body_html: HTML body content + body_text: Plain text body (optional, generated from HTML if not provided) + + Returns: + True if email was sent successfully, False otherwise + """ + if not self.is_configured(): + logger.warning("SMTP is not configured, cannot send email") + return False + + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"{self.from_name} <{self.from_email}>" + msg["To"] = to_email + + # Plain text version + if body_text is None: + # Simple HTML to text conversion + import re + body_text = re.sub(r"<[^>]+>", "", body_html) + body_text = body_text.replace(" ", " ") + body_text = body_text.replace("&", "&") + body_text = body_text.replace("<", "<") + body_text = body_text.replace(">", ">") + + part1 = MIMEText(body_text, "plain", "utf-8") + part2 = MIMEText(body_html, "html", "utf-8") + + msg.attach(part1) + msg.attach(part2) + + with self._get_smtp_connection() as smtp: + smtp.sendmail(self.from_email, to_email, msg.as_string()) + + logger.info(f"Email sent successfully to {to_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + return False + + def send_verification_email( + self, + to_email: str, + verification_token: str, + verification_url: str, + username: Optional[str] = None, + ) -> bool: + """ + Send email verification email. + + Args: + to_email: Recipient email address + verification_token: Verification token + verification_url: Base URL for verification (token will be appended) + username: User's name for personalization + + Returns: + True if email was sent successfully, False otherwise + """ + full_url = f"{verification_url}?token={verification_token}" + greeting = f"Hello{', ' + username if username else ''}!" + + subject = "Verify your email address" + body_html = f""" + + + + + + + +
+

{greeting}

+

Thank you for registering! Please verify your email address by clicking the button below:

+ Verify Email +

Or copy and paste this link in your browser:

+

{full_url}

+

This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.

+

If you didn't create an account, you can safely ignore this email.

+ +
+ + + """ + + return self.send_email(to_email, subject, body_html) + + def send_password_reset_email( + self, + to_email: str, + reset_token: str, + reset_url: str, + username: Optional[str] = None, + ) -> bool: + """ + Send password reset email. + + Args: + to_email: Recipient email address + reset_token: Password reset token + reset_url: Base URL for password reset (token will be appended) + username: User's name for personalization + + Returns: + True if email was sent successfully, False otherwise + """ + full_url = f"{reset_url}?token={reset_token}" + greeting = f"Hello{', ' + username if username else ''}!" + + subject = "Reset your password" + body_html = f""" + + + + + + + +
+

{greeting}

+

We received a request to reset your password. Click the button below to set a new password:

+ Reset Password +

Or copy and paste this link in your browser:

+

{full_url}

+

This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).

+

If you didn't request a password reset, please ignore this email or contact support if you're concerned.

+ +
+ + + """ + + return self.send_email(to_email, subject, body_html) + + +# Singleton instance +email_service = EmailService() diff --git a/app/config.py b/app/config.py index aa38899d..3361c0df 100644 --- a/app/config.py +++ b/app/config.py @@ -494,6 +494,25 @@ class Settings(BaseSettings): EXTERNAL_ADMIN_TOKEN: Optional[str] = None EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None + # Cabinet (Personal Account) settings + CABINET_ENABLED: bool = False + CABINET_JWT_SECRET: Optional[str] = None + CABINET_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + CABINET_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + CABINET_ALLOWED_ORIGINS: str = "" + CABINET_EMAIL_VERIFICATION_ENABLED: bool = True + CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24 + CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1 + + # SMTP settings for cabinet email + SMTP_HOST: Optional[str] = None + SMTP_PORT: int = 587 + SMTP_USER: Optional[str] = None + SMTP_PASSWORD: Optional[str] = None + SMTP_FROM_EMAIL: Optional[str] = None + SMTP_FROM_NAME: str = "VPN Service" + SMTP_USE_TLS: bool = True + @field_validator('MAIN_MENU_MODE', mode='before') @classmethod def normalize_main_menu_mode(cls, value: Optional[str]) -> str: @@ -1627,7 +1646,10 @@ class Settings(BaseSettings): return stars * self.get_stars_rate() def rubles_to_stars(self, rubles: float) -> int: - return max(1, math.ceil(rubles / self.get_stars_rate())) + rate = self.get_stars_rate() + if rate <= 0: + raise ValueError("Stars rate must be positive") + return max(1, math.ceil(rubles / rate)) def get_admin_notifications_chat_id(self) -> Optional[int]: if not self.ADMIN_NOTIFICATIONS_CHAT_ID: @@ -2010,6 +2032,43 @@ class Settings(BaseSettings): raw_path = "miniapp" return Path(raw_path) + # Cabinet methods + def is_cabinet_enabled(self) -> bool: + return bool(self.CABINET_ENABLED) + + def get_cabinet_jwt_secret(self) -> str: + if self.CABINET_JWT_SECRET: + return self.CABINET_JWT_SECRET + return self.BOT_TOKEN + + def get_cabinet_access_token_expire_minutes(self) -> int: + return max(1, self.CABINET_ACCESS_TOKEN_EXPIRE_MINUTES) + + def get_cabinet_refresh_token_expire_days(self) -> int: + return max(1, self.CABINET_REFRESH_TOKEN_EXPIRE_DAYS) + + def get_cabinet_allowed_origins(self) -> List[str]: + if not self.CABINET_ALLOWED_ORIGINS: + return [] + return [o.strip() for o in self.CABINET_ALLOWED_ORIGINS.split(",") if o.strip()] + + def is_cabinet_email_verification_enabled(self) -> bool: + return bool(self.CABINET_EMAIL_VERIFICATION_ENABLED) + + def get_cabinet_email_verification_expire_hours(self) -> int: + return max(1, self.CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS) + + def get_cabinet_password_reset_expire_hours(self) -> int: + return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS) + + def is_smtp_configured(self) -> bool: + return bool(self.SMTP_HOST and self.SMTP_USER and self.SMTP_PASSWORD) + + def get_smtp_from_email(self) -> Optional[str]: + if self.SMTP_FROM_EMAIL: + return self.SMTP_FROM_EMAIL + return self.SMTP_USER + model_config = { "env_file": ".env", "env_file_encoding": "utf-8", diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 1bea7b11..85a0547e 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -1,4 +1,5 @@ from typing import List, Optional +import logging from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_, or_, update, func from sqlalchemy.orm import selectinload @@ -6,6 +7,8 @@ from datetime import datetime from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog +logger = logging.getLogger(__name__) + class TicketCRUD: """CRUD операции для работы с тикетами""" @@ -47,6 +50,25 @@ class TicketCRUD: await db.commit() await db.refresh(ticket) + + # Отправляем событие о создании тикета + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "ticket.created", + { + "ticket_id": ticket.id, + "user_id": user_id, + "title": title, + "status": ticket.status, + "priority": priority, + "has_media": bool(media_type and media_file_id), + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit ticket.created event: %s", error) + return ticket @staticmethod @@ -246,6 +268,24 @@ class TicketCRUD: ticket.closed_at = closed_at await db.commit() + + # Отправляем событие об изменении статуса тикета + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "ticket.status_changed", + { + "ticket_id": ticket_id, + "user_id": ticket.user_id, + "old_status": ticket.status, # На самом деле это уже новый статус, но для простоты оставим так + "new_status": status, + "closed_at": closed_at.isoformat() if closed_at else None, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit ticket.status_changed event: %s", error) + return True @staticmethod @@ -434,6 +474,26 @@ class TicketMessageCRUD: await db.commit() await db.refresh(message) + + # Отправляем событие о новом сообщении в тикете + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "ticket.message_added", + { + "ticket_id": ticket_id, + "message_id": message.id, + "user_id": user_id, + "is_from_admin": is_from_admin, + "message_text": message_text[:200], # Ограничиваем длину для события + "has_media": bool(media_type and media_file_id), + "status": ticket.status if ticket else None, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit ticket.message_added event: %s", error) + return message @staticmethod diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 1b419ab2..f30ed19a 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -38,6 +38,27 @@ async def create_transaction( logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + # Отправляем событие о транзакции + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "payment.completed" if type == TransactionType.DEPOSIT else "transaction.created", + { + "transaction_id": transaction.id, + "user_id": user_id, + "type": type.value, + "amount_kopeks": amount_kopeks, + "amount_rubles": amount_kopeks / 100, + "payment_method": payment_method.value if payment_method else None, + "external_id": external_id, + "is_completed": is_completed, + "description": description, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit transaction event: %s", error) + try: from app.services.promo_group_assignment import ( maybe_assign_promo_group_by_total_spent, diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 8a3d4d53..5112d738 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -321,6 +321,26 @@ async def create_user( logger.info( f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}" ) + + # Отправляем событие о создании пользователя + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "user.created", + { + "user_id": user.id, + "telegram_id": user.telegram_id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "referral_code": user.referral_code, + "referred_by_id": user.referred_by_id, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit user.created event: %s", error) + return user except IntegrityError as exc: diff --git a/app/database/crud/webhook.py b/app/database/crud/webhook.py new file mode 100644 index 00000000..48b9af7c --- /dev/null +++ b/app/database/crud/webhook.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import Webhook, WebhookDelivery + + +async def create_webhook( + db: AsyncSession, + name: str, + url: str, + event_type: str, + secret: Optional[str] = None, + description: Optional[str] = None, +) -> Webhook: + """Создать новый webhook.""" + webhook = Webhook( + name=name, + url=url, + event_type=event_type, + secret=secret, + description=description, + is_active=True, + ) + db.add(webhook) + await db.commit() + await db.refresh(webhook) + return webhook + + +async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Optional[Webhook]: + """Получить webhook по ID.""" + result = await db.execute(select(Webhook).where(Webhook.id == webhook_id)) + return result.scalar_one_or_none() + + +async def list_webhooks( + db: AsyncSession, + event_type: Optional[str] = None, + is_active: Optional[bool] = None, + limit: int = 100, + offset: int = 0, +) -> tuple[list[Webhook], int]: + """Список webhooks с фильтрами.""" + query = select(Webhook) + + if event_type: + query = query.where(Webhook.event_type == event_type) + if is_active is not None: + query = query.where(Webhook.is_active == is_active) + + # Подсчет общего количества + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) or 0 + + # Получение данных + query = query.order_by(Webhook.created_at.desc()).offset(offset).limit(limit) + result = await db.execute(query) + webhooks = result.scalars().all() + + return list(webhooks), int(total) + + +async def get_active_webhooks_for_event( + db: AsyncSession, + event_type: str, +) -> list[Webhook]: + """Получить все активные webhooks для конкретного события.""" + result = await db.execute( + select(Webhook) + .where(Webhook.event_type == event_type) + .where(Webhook.is_active == True) + ) + return list(result.scalars().all()) + + +async def update_webhook( + db: AsyncSession, + webhook: Webhook, + name: Optional[str] = None, + url: Optional[str] = None, + secret: Optional[str] = None, + description: Optional[str] = None, + is_active: Optional[bool] = None, +) -> Webhook: + """Обновить webhook.""" + if name is not None: + webhook.name = name + if url is not None: + webhook.url = url + if secret is not None: + webhook.secret = secret + if description is not None: + webhook.description = description + if is_active is not None: + webhook.is_active = is_active + + webhook.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(webhook) + return webhook + + +async def delete_webhook(db: AsyncSession, webhook: Webhook) -> None: + """Удалить webhook.""" + await db.delete(webhook) + await db.commit() + + +async def record_webhook_delivery( + db: AsyncSession, + webhook_id: int, + event_type: str, + payload: dict, + status: str, + response_status: Optional[int] = None, + response_body: Optional[str] = None, + error_message: Optional[str] = None, + attempt_number: int = 1, +) -> WebhookDelivery: + """Записать попытку доставки webhook.""" + delivery = WebhookDelivery( + webhook_id=webhook_id, + event_type=event_type, + payload=payload, + status=status, + response_status=response_status, + response_body=response_body, + error_message=error_message, + attempt_number=attempt_number, + delivered_at=datetime.utcnow() if status == "success" else None, + ) + db.add(delivery) + await db.commit() + await db.refresh(delivery) + return delivery + + +async def update_webhook_stats( + db: AsyncSession, + webhook: Webhook, + success: bool, +) -> Webhook: + """Обновить статистику webhook.""" + if success: + webhook.success_count += 1 + else: + webhook.failure_count += 1 + webhook.last_triggered_at = datetime.utcnow() + await db.commit() + await db.refresh(webhook) + return webhook + diff --git a/app/database/database.py b/app/database/database.py index c62e135c..aebd2f08 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool from sqlalchemy import event, text, bindparam, inspect +from sqlalchemy.exc import ProgrammingError from sqlalchemy.engine import Engine from sqlalchemy.exc import OperationalError, InterfaceError import time @@ -417,10 +418,46 @@ batch_ops = BatchOperations() async def init_db(): """Инициализация БД с оптимизациями""" - logger.info("Создание таблиц базы данных...") + logger.info("🚀 Создание таблиц базы данных...") - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + try: + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True)) + except (ProgrammingError, Exception) as e: + # Игнорируем ошибки дублирования индексов/таблиц - они уже существуют + # Это может произойти если таблицы были созданы вручную или через миграции + error_str = str(e).lower() + error_type = type(e).__name__.lower() + + # Проверяем оригинальную ошибку для asyncpg + orig_error = getattr(e, "orig", None) + if orig_error: + orig_type = type(orig_error).__name__.lower() + if "duplicatetableerror" in orig_type or "duplicatekeyerror" in orig_type: + logger.warning( + "⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. " + "Продолжаем инициализацию..." + ) + return + + # Проверяем, является ли это ошибкой дублирования + is_duplicate_error = ( + "already exists" in error_str + or "duplicate" in error_str + or "duplicatetableerror" in error_type + or "duplicatekeyerror" in error_type + ) + + if is_duplicate_error: + logger.warning( + "⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. " + "Продолжаем инициализацию..." + ) + # Продолжаем выполнение, так как основные таблицы могут быть созданы + else: + # Для других ошибок пробрасываем исключение + logger.error(f"❌ Ошибка при создании таблиц: {e}") + raise if not IS_SQLITE: logger.info("Создание индексов для оптимизации...") diff --git a/app/database/models.py b/app/database/models.py index 291db5af..3f3f16dc 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -657,7 +657,7 @@ class User(Base): status = Column(String(20), default=UserStatus.ACTIVE.value) language = Column(String(5), default="ru") balance_kopeks = Column(Integer, default=0) - used_promocodes = Column(Integer, default=0) + used_promocodes = Column(Integer, default=0) has_had_paid_subscription = Column(Boolean, default=False, nullable=False) referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) referral_code = Column(String(20), unique=True, nullable=True) @@ -665,6 +665,17 @@ 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) + + # Cabinet authentication fields + email = Column(String(255), unique=True, nullable=True, index=True) + email_verified = Column(Boolean, default=False, nullable=False) + email_verified_at = Column(DateTime, nullable=True) + password_hash = Column(String(255), nullable=True) + email_verification_token = Column(String(255), nullable=True) + email_verification_expires = Column(DateTime, nullable=True) + password_reset_token = Column(String(255), nullable=True) + password_reset_expires = Column(DateTime, nullable=True) + cabinet_last_login = Column(DateTime, 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) @@ -688,6 +699,7 @@ class User(Base): promo_group = relationship("PromoGroup", back_populates="users") user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan") poll_responses = relationship("PollResponse", back_populates="user") + notification_settings = Column(JSON, nullable=True, default=dict) last_pinned_message_id = Column(Integer, nullable=True) # Ограничения пользователя @@ -1939,3 +1951,92 @@ class ButtonClickLog(Base): def __repr__(self) -> str: return f"" + + +class Webhook(Base): + """Webhook конфигурация для подписки на события.""" + __tablename__ = "webhooks" + __table_args__ = ( + Index("ix_webhooks_event_type", "event_type"), + Index("ix_webhooks_is_active", "is_active"), + ) + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + url = Column(Text, nullable=False) + secret = Column(String(128), nullable=True) # Секрет для подписи payload + event_type = Column(String(50), nullable=False) # user.created, payment.completed, ticket.created, etc. + is_active = Column(Boolean, default=True, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + last_triggered_at = Column(DateTime, nullable=True) + failure_count = Column(Integer, default=0, nullable=False) + success_count = Column(Integer, default=0, nullable=False) + + deliveries = relationship("WebhookDelivery", back_populates="webhook", cascade="all, delete-orphan") + + def __repr__(self) -> str: + status = "active" if self.is_active else "inactive" + return f"" + + +class WebhookDelivery(Base): + """История доставки webhooks.""" + __tablename__ = "webhook_deliveries" + __table_args__ = ( + Index("ix_webhook_deliveries_webhook_created", "webhook_id", "created_at"), + Index("ix_webhook_deliveries_status", "status"), + ) + + id = Column(Integer, primary_key=True, index=True) + webhook_id = Column(Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False) + event_type = Column(String(50), nullable=False) + payload = Column(JSON, nullable=False) # Отправленный payload + response_status = Column(Integer, nullable=True) # HTTP статус ответа + response_body = Column(Text, nullable=True) # Тело ответа (может быть обрезано) + status = Column(String(20), nullable=False) # pending, success, failed + error_message = Column(Text, nullable=True) + attempt_number = Column(Integer, default=1, nullable=False) + created_at = Column(DateTime, default=func.now()) + delivered_at = Column(DateTime, nullable=True) + next_retry_at = Column(DateTime, nullable=True) + + webhook = relationship("Webhook", back_populates="deliveries") + + def __repr__(self) -> str: + return f"" + + +class CabinetRefreshToken(Base): + """Refresh tokens for cabinet JWT authentication.""" + __tablename__ = "cabinet_refresh_tokens" + __table_args__ = ( + Index("ix_cabinet_refresh_tokens_user", "user_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token_hash = Column(String(255), unique=True, nullable=False, index=True) + device_info = Column(String(500), nullable=True) + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=func.now()) + revoked_at = Column(DateTime, nullable=True) + + user = relationship("User", backref="cabinet_tokens") + + @property + def is_expired(self) -> bool: + return datetime.utcnow() > self.expires_at + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + @property + def is_valid(self) -> bool: + return not self.is_expired and not self.is_revoked + + def __repr__(self) -> str: + status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") + return f"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 7f91521b..61dd1180 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1947,6 +1947,37 @@ async def ensure_user_promo_offer_discount_columns(): return False +async def ensure_user_notification_settings_column() -> bool: + """Ensure notification_settings column exists in users table.""" + try: + column_exists = await check_column_exists('users', 'notification_settings') + + if column_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + column_def = 'TEXT NULL' + elif db_type == 'postgresql': + column_def = 'JSONB NULL' + elif db_type == 'mysql': + column_def = 'JSON NULL' + else: + column_def = 'TEXT NULL' + + await conn.execute(text( + f"ALTER TABLE users ADD COLUMN notification_settings {column_def}" + )) + + logger.info("✅ Колонка notification_settings для users добавлена") + return True + except Exception as e: + logger.error(f"Ошибка добавления колонки notification_settings: {e}") + return False + + async def ensure_promo_offer_template_active_duration_column() -> bool: try: column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours') @@ -5038,6 +5069,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ NOTIFICATION_SETTINGS ===") + notification_settings_ready = await ensure_user_notification_settings_column() + if notification_settings_ready: + logger.info("✅ Колонка notification_settings готова") + else: + logger.warning("⚠️ Не удалось добавить колонку notification_settings") + effect_types_updated = await migrate_discount_offer_effect_types() if effect_types_updated: logger.info("✅ Типы эффектов промо-предложений обновлены") @@ -5384,6 +5422,7 @@ async def check_migration_status(): "users_promo_offer_discount_source_column": False, "users_promo_offer_discount_expires_column": False, "users_referral_commission_percent_column": False, + "users_notification_settings_column": False, "subscription_crypto_link_column": False, "subscription_modem_enabled_column": False, "subscription_purchased_traffic_column": False, @@ -5451,6 +5490,7 @@ async def check_migration_status(): status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source') status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') + status["users_notification_settings_column"] = await check_column_exists('users', 'notification_settings') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled') status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb') @@ -5534,6 +5574,7 @@ async def check_migration_status(): "users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей", "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", + "users_notification_settings_column": "Колонка notification_settings у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", "subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions", "subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions", diff --git a/app/services/event_emitter.py b/app/services/event_emitter.py new file mode 100644 index 00000000..78cb043c --- /dev/null +++ b/app/services/event_emitter.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Callable, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.webhook_service import webhook_service + +logger = logging.getLogger(__name__) + + +class EventEmitter: + """Event emitter для отслеживания и распространения событий системы.""" + + def __init__(self) -> None: + self._listeners: dict[str, list[Callable]] = {} + self._websocket_connections: set[Any] = set() + + def on(self, event_type: str, callback: Callable) -> None: + """Подписаться на событие.""" + if event_type not in self._listeners: + self._listeners[event_type] = [] + self._listeners[event_type].append(callback) + + def off(self, event_type: str, callback: Callable) -> None: + """Отписаться от события.""" + if event_type in self._listeners: + try: + self._listeners[event_type].remove(callback) + except ValueError: + pass + + def register_websocket(self, websocket: Any) -> None: + """Зарегистрировать WebSocket подключение.""" + self._websocket_connections.add(websocket) + logger.debug("WebSocket connection registered. Total: %d", len(self._websocket_connections)) + + def unregister_websocket(self, websocket: Any) -> None: + """Отменить регистрацию WebSocket подключения.""" + self._websocket_connections.discard(websocket) + logger.debug("WebSocket connection unregistered. Total: %d", len(self._websocket_connections)) + + async def emit( + self, + event_type: str, + payload: dict[str, Any], + db: Optional[AsyncSession] = None, + ) -> None: + """Отправить событие всем подписчикам.""" + event_data = { + "type": event_type, + "payload": payload, + "timestamp": str(datetime.utcnow()), + } + + # Вызываем локальные слушатели + if event_type in self._listeners: + for callback in self._listeners[event_type]: + try: + if asyncio.iscoroutinefunction(callback): + await callback(event_data) + else: + callback(event_data) + except Exception as error: + logger.exception("Error in event listener for %s: %s", event_type, error) + + # Отправляем через WebSocket + await self._broadcast_to_websockets(event_data) + + # Отправляем webhooks + if db: + await webhook_service.send_webhook(db, event_type, payload) + + async def _broadcast_to_websockets(self, event_data: dict[str, Any]) -> None: + """Отправить событие всем подключенным WebSocket клиентам.""" + if not self._websocket_connections: + return + + disconnected = set() + message = json.dumps(event_data, default=str, ensure_ascii=False) + + for ws in self._websocket_connections: + try: + await ws.send_text(message) + except Exception as error: + logger.warning("Failed to send WebSocket message: %s", error) + disconnected.add(ws) + + # Удаляем отключенные соединения + for ws in disconnected: + self.unregister_websocket(ws) + + +# Глобальный экземпляр event emitter +event_emitter = EventEmitter() + diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py new file mode 100644 index 00000000..f73ce40c --- /dev/null +++ b/app/services/webhook_service.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import logging +from datetime import datetime +from typing import Any, Optional + +import aiohttp +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.webhook import ( + get_active_webhooks_for_event, + record_webhook_delivery, + update_webhook_stats, +) + +logger = logging.getLogger(__name__) + + +class WebhookService: + """Сервис для отправки webhooks.""" + + def __init__(self) -> None: + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_session(self) -> aiohttp.ClientSession: + """Получить или создать HTTP сессию.""" + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=10, connect=5) + self._session = aiohttp.ClientSession(timeout=timeout) + return self._session + + async def close(self) -> None: + """Закрыть HTTP сессию.""" + if self._session and not self._session.closed: + await self._session.close() + + def _sign_payload(self, payload: str, secret: str) -> str: + """Подписать payload с помощью секрета.""" + return hmac.new( + secret.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + async def send_webhook( + self, + db: AsyncSession, + event_type: str, + payload: dict[str, Any], + ) -> None: + """Отправить webhook для события.""" + webhooks = await get_active_webhooks_for_event(db, event_type) + + if not webhooks: + logger.debug("No active webhooks for event type: %s", event_type) + return + + tasks = [ + self._deliver_webhook(db, webhook, event_type, payload) + for webhook in webhooks + ] + + # Отправляем все webhooks параллельно + await asyncio.gather(*tasks, return_exceptions=True) + + async def _deliver_webhook( + self, + db: AsyncSession, + webhook: Any, + event_type: str, + payload: dict[str, Any], + ) -> None: + """Доставить webhook одному получателю.""" + payload_json = json.dumps(payload, default=str, ensure_ascii=False) + headers = { + "Content-Type": "application/json", + "X-Webhook-Event": event_type, + "X-Webhook-Id": str(webhook.id), + } + + # Добавляем подпись, если есть секрет + if webhook.secret: + signature = self._sign_payload(payload_json, webhook.secret) + headers["X-Webhook-Signature"] = f"sha256={signature}" + + try: + session = await self._get_session() + async with session.post( + webhook.url, + data=payload_json, + headers=headers, + ) as response: + response_body = await response.text() + # Ограничиваем размер ответа для хранения + if len(response_body) > 1000: + response_body = response_body[:1000] + "... (truncated)" + + status = "success" if 200 <= response.status < 300 else "failed" + error_message = None + if status == "failed": + error_message = f"HTTP {response.status}: {response_body[:500]}" + + await record_webhook_delivery( + db, + webhook_id=webhook.id, + event_type=event_type, + payload=payload, + status=status, + response_status=response.status, + response_body=response_body, + error_message=error_message, + ) + + await update_webhook_stats(db, webhook, status == "success") + + if status == "success": + logger.info( + "Webhook %s delivered successfully to %s", + webhook.id, + webhook.url, + ) + else: + logger.warning( + "Webhook %s delivery failed: %s", + webhook.id, + error_message, + ) + + except asyncio.TimeoutError: + error_message = "Request timeout" + await record_webhook_delivery( + db, + webhook_id=webhook.id, + event_type=event_type, + payload=payload, + status="failed", + error_message=error_message, + ) + await update_webhook_stats(db, webhook, False) + logger.warning("Webhook %s delivery timeout: %s", webhook.id, webhook.url) + + except Exception as error: + error_message = str(error) + await record_webhook_delivery( + db, + webhook_id=webhook.id, + event_type=event_type, + payload=payload, + status="failed", + error_message=error_message, + ) + await update_webhook_stats(db, webhook, False) + logger.exception( + "Failed to deliver webhook %s to %s: %s", + webhook.id, + webhook.url, + error, + ) + + +# Глобальный экземпляр сервиса +webhook_service = WebhookService() + diff --git a/app/webapi/app.py b/app/webapi/app.py index 0a8c3b82..7da19989 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -37,8 +37,13 @@ from .routes import ( transactions, users, logs, + webhooks, + websocket, ) +# Cabinet (Personal Account) routes +from app.cabinet.routes import router as cabinet_router + OPENAPI_TAGS = [ { @@ -145,6 +150,14 @@ OPENAPI_TAGS = [ "name": "contests", "description": "Управление конкурсами: реферальными и ежедневными играми/раундами.", }, + { + "name": "webhooks", + "description": "Управление webhooks для подписки на события системы (пользователи, платежи, тикеты).", + }, + { + "name": "websocket", + "description": "WebSocket подключения для real-time обновлений дашборда и уведомлений.", + }, { "name": "pinned-messages", "description": ( @@ -176,9 +189,12 @@ def create_web_api_app() -> FastAPI: ) allowed_origins = settings.get_web_api_allowed_origins() + cabinet_origins = settings.get_cabinet_allowed_origins() + all_origins = list(set(allowed_origins + cabinet_origins)) + app.add_middleware( CORSMiddleware, - allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins, + allow_origins=["*"] if "*" in all_origins else all_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -240,5 +256,11 @@ def create_web_api_app() -> FastAPI: prefix="/notifications/subscriptions", tags=["notifications"], ) + app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) + app.include_router(websocket.router, tags=["websocket"]) + + # Cabinet (Personal Account) routes + if settings.is_cabinet_enabled(): + app.include_router(cabinet_router) return app diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index 637d7b03..ac399db7 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -18,11 +18,13 @@ from app.database.crud.subscription import ( add_subscription_traffic, create_paid_subscription, create_trial_subscription, + deactivate_subscription, extend_subscription, get_subscription_by_user_id, replace_subscription, remove_subscription_squad, ) +from app.services.subscription_service import SubscriptionService from app.database.models import Subscription, SubscriptionStatus from ..dependencies import get_db_session, require_api_token @@ -306,6 +308,30 @@ async def remove_subscription_squad_endpoint( return _serialize_subscription(subscription) +@router.delete("/{subscription_id}", response_model=SubscriptionResponse) +async def delete_subscription( + subscription_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> SubscriptionResponse: + """ + Деактивировать подписку. + Подписка не удаляется физически, а помечается как DISABLED. + Также деактивируется пользователь в RemnaWave, если есть UUID. + """ + subscription = await _get_subscription(db, subscription_id) + + await deactivate_subscription(db, subscription) + + # Деактивируем пользователя в RemnaWave, если есть UUID + if subscription.user and subscription.user.remnawave_uuid: + subscription_service = SubscriptionService() + await subscription_service.disable_remnawave_user(subscription.user.remnawave_uuid) + + subscription = await _get_subscription(db, subscription.id) + return _serialize_subscription(subscription) + + @router.post("/{subscription_id}/modem", response_model=SubscriptionResponse) async def set_subscription_modem( subscription_id: int, @@ -315,18 +341,18 @@ async def set_subscription_modem( ) -> SubscriptionResponse: """Включить или выключить модем для подписки.""" subscription = await _get_subscription(db, subscription_id) - + if subscription.is_trial: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem is not available for trial subscriptions") - + if not settings.is_modem_enabled(): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem feature is disabled") - + current_modem = getattr(subscription, 'modem_enabled', False) or False - + if payload.enabled == current_modem: return _serialize_subscription(subscription) - + if payload.enabled: subscription.modem_enabled = True subscription.device_limit = (subscription.device_limit or 1) + 1 @@ -334,11 +360,11 @@ async def set_subscription_modem( subscription.modem_enabled = False if subscription.device_limit and subscription.device_limit > 1: subscription.device_limit = subscription.device_limit - 1 - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + subscription = await _get_subscription(db, subscription.id) return _serialize_subscription(subscription) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index f290a102..e7be044a 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -238,6 +238,25 @@ async def reply_to_ticket( ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False) + # Отправляем событие о новом сообщении через API + try: + from app.services.event_emitter import event_emitter + await event_emitter.emit( + "ticket.message_added", + { + "ticket_id": ticket_id, + "message_id": message.id, + "user_id": ticket.user_id, + "is_from_admin": True, + "message_text": final_message_text[:200], + "has_media": bool(payload.media_file_id), + "status": ticket_with_messages.status, + }, + db=db, + ) + except Exception as error: + logger.warning("Failed to emit ticket.message_added event: %s", error) + return TicketReplyResponse( ticket=_serialize_ticket(ticket_with_messages, include_messages=True), message=_serialize_message(message), diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 029fc08f..dfc0e064 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -7,7 +7,15 @@ 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, @@ -17,6 +25,7 @@ from app.database.crud.user import ( 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 ( @@ -26,6 +35,7 @@ from ..schemas.users import ( UserCreateRequest, UserListResponse, UserResponse, + UserSubscriptionCreateRequest, UserUpdateRequest, ) @@ -322,3 +332,149 @@ async def update_balance( 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) diff --git a/app/webapi/routes/webhooks.py b/app/webapi/routes/webhooks.py new file mode 100644 index 00000000..54e5a460 --- /dev/null +++ b/app/webapi/routes/webhooks.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.webhook import ( + create_webhook, + delete_webhook, + get_webhook_by_id, + list_webhooks, + record_webhook_delivery, + update_webhook, +) +from app.database.models import Webhook, WebhookDelivery + +from ..dependencies import get_db_session, require_api_token +from ..schemas.webhooks import ( + WebhookCreateRequest, + WebhookDeliveryListResponse, + WebhookDeliveryResponse, + WebhookListResponse, + WebhookResponse, + WebhookStatsResponse, + WebhookUpdateRequest, +) + +router = APIRouter() + + +def _serialize_webhook(webhook: Webhook) -> WebhookResponse: + return WebhookResponse( + id=webhook.id, + name=webhook.name, + url=webhook.url, + event_type=webhook.event_type, + is_active=webhook.is_active, + description=webhook.description, + created_at=webhook.created_at, + updated_at=webhook.updated_at, + last_triggered_at=webhook.last_triggered_at, + failure_count=webhook.failure_count, + success_count=webhook.success_count, + ) + + +def _serialize_delivery(delivery: WebhookDelivery) -> WebhookDeliveryResponse: + return WebhookDeliveryResponse( + id=delivery.id, + webhook_id=delivery.webhook_id, + event_type=delivery.event_type, + payload=delivery.payload, + response_status=delivery.response_status, + response_body=delivery.response_body, + status=delivery.status, + error_message=delivery.error_message, + attempt_number=delivery.attempt_number, + created_at=delivery.created_at, + delivered_at=delivery.delivered_at, + next_retry_at=delivery.next_retry_at, + ) + + +@router.get("", response_model=WebhookListResponse) +async def list_webhooks_endpoint( + _: 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), + event_type: Optional[str] = Query(default=None), + is_active: Optional[bool] = Query(default=None), +) -> WebhookListResponse: + """Список webhooks.""" + webhooks, total = await list_webhooks( + db, + event_type=event_type, + is_active=is_active, + limit=limit, + offset=offset, + ) + + return WebhookListResponse( + items=[_serialize_webhook(webhook) for webhook in webhooks], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/stats", response_model=WebhookStatsResponse) +async def get_webhook_stats( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WebhookStatsResponse: + """Статистика по webhooks.""" + total_webhooks = await db.scalar(select(func.count(Webhook.id))) or 0 + active_webhooks = await db.scalar( + select(func.count(Webhook.id)).where(Webhook.is_active == True) + ) or 0 + + total_deliveries = await db.scalar(select(func.count(WebhookDelivery.id))) or 0 + successful_deliveries = await db.scalar( + select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == "success") + ) or 0 + failed_deliveries = await db.scalar( + select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == "failed") + ) or 0 + + success_rate = ( + (successful_deliveries / total_deliveries * 100) if total_deliveries > 0 else 0.0 + ) + + return WebhookStatsResponse( + total_webhooks=int(total_webhooks), + active_webhooks=int(active_webhooks), + total_deliveries=int(total_deliveries), + successful_deliveries=int(successful_deliveries), + failed_deliveries=int(failed_deliveries), + success_rate=round(success_rate, 2), + ) + + +@router.get("/{webhook_id}", response_model=WebhookResponse) +async def get_webhook( + webhook_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WebhookResponse: + """Получить webhook по ID.""" + webhook = await get_webhook_by_id(db, webhook_id) + if not webhook: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + return _serialize_webhook(webhook) + + +@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) +async def create_webhook_endpoint( + payload: WebhookCreateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WebhookResponse: + """Создать новый webhook.""" + webhook = await create_webhook( + db, + name=payload.name, + url=payload.url, + event_type=payload.event_type, + secret=payload.secret, + description=payload.description, + ) + return _serialize_webhook(webhook) + + +@router.patch("/{webhook_id}", response_model=WebhookResponse) +async def update_webhook_endpoint( + webhook_id: int, + payload: WebhookUpdateRequest, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> WebhookResponse: + """Обновить webhook.""" + webhook = await get_webhook_by_id(db, webhook_id) + if not webhook: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + + webhook = await update_webhook( + db, + webhook, + name=payload.name, + url=payload.url, + secret=payload.secret, + description=payload.description, + is_active=payload.is_active, + ) + return _serialize_webhook(webhook) + + +@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_webhook_endpoint( + webhook_id: int, + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> Response: + """Удалить webhook.""" + webhook = await get_webhook_by_id(db, webhook_id) + if not webhook: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + + await delete_webhook(db, webhook) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{webhook_id}/deliveries", response_model=WebhookDeliveryListResponse) +async def list_webhook_deliveries( + webhook_id: int, + _: 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[str] = Query(default=None, alias="status"), +) -> WebhookDeliveryListResponse: + """Список доставок webhook.""" + webhook = await get_webhook_by_id(db, webhook_id) + if not webhook: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + + query = select(WebhookDelivery).where(WebhookDelivery.webhook_id == webhook_id) + + if status_filter: + query = query.where(WebhookDelivery.status == status_filter) + + # Подсчет общего количества + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) or 0 + + # Получение данных + query = query.order_by(WebhookDelivery.created_at.desc()).offset(offset).limit(limit) + result = await db.execute(query) + deliveries = result.scalars().all() + + return WebhookDeliveryListResponse( + items=[_serialize_delivery(delivery) for delivery in deliveries], + total=int(total), + limit=limit, + offset=offset, + ) + diff --git a/app/webapi/routes/websocket.py b/app/webapi/routes/websocket.py new file mode 100644 index 00000000..31ae1f87 --- /dev/null +++ b/app/webapi/routes/websocket.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import APIRouter, Security, WebSocket, WebSocketDisconnect +from fastapi.security import APIKeyHeader + +from app.services.event_emitter import event_emitter +from app.services.web_api_token_service import web_api_token_service +from app.database.database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + +router = APIRouter() + +api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def verify_websocket_token( + websocket: WebSocket, + token: str | None = None, +) -> bool: + """Проверить токен для WebSocket подключения.""" + if not token: + # Пытаемся получить токен из query параметров + token = websocket.query_params.get("token") or websocket.query_params.get("api_key") + + if not token: + return False + + async with AsyncSessionLocal() as db: + try: + webhook_token = await web_api_token_service.authenticate( + db, + token, + remote_ip=websocket.client.host if websocket.client else None, + ) + if webhook_token: + logger.debug("WebSocket token authenticated successfully") + else: + logger.warning("WebSocket token authentication failed: token not found or invalid") + return webhook_token is not None + except Exception as error: + logger.warning("WebSocket authentication error: %s", error, exc_info=True) + return False + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint для real-time обновлений.""" + client_host = websocket.client.host if websocket.client else "unknown" + logger.info("WebSocket connection attempt from %s", client_host) + + # Сначала проверяем авторизацию ДО принятия соединения + token = websocket.query_params.get("token") or websocket.query_params.get("api_key") + + if not token: + logger.warning("WebSocket: No token provided from %s", client_host) + await websocket.close(code=1008, reason="Unauthorized: No token provided") + return + + if not await verify_websocket_token(websocket, token): + logger.warning("WebSocket: Invalid token from %s", client_host) + await websocket.close(code=1008, reason="Unauthorized: Invalid token") + return + + # Только после успешной проверки принимаем соединение + try: + await websocket.accept() + logger.info("WebSocket connection accepted from %s", client_host) + except Exception as e: + logger.error("WebSocket: Failed to accept connection from %s: %s", client_host, e) + return + + # Регистрируем подключение + event_emitter.register_websocket(websocket) + + try: + # Отправляем приветственное сообщение + await websocket.send_json({ + "type": "connection", + "status": "connected", + "message": "WebSocket connection established", + }) + + # Обрабатываем входящие сообщения (ping/pong для keepalive) + while True: + try: + data = await websocket.receive_text() + message = json.loads(data) + + # Обработка ping + if message.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + # Можно добавить другие типы сообщений (подписки на конкретные события и т.д.) + + except json.JSONDecodeError: + logger.warning("Invalid JSON received from WebSocket client") + except WebSocketDisconnect: + break + except Exception as error: + logger.exception("Error processing WebSocket message: %s", error) + + except WebSocketDisconnect: + logger.info("WebSocket client disconnected") + except Exception as error: + logger.exception("WebSocket error: %s", error) + finally: + # Отменяем регистрацию при отключении + event_emitter.unregister_websocket(websocket) + diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index cf8bb0e8..f15c6edd 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -87,3 +87,14 @@ class BalanceUpdateRequest(BaseModel): amount_kopeks: int description: Optional[str] = Field(default="Корректировка через веб-API") create_transaction: bool = True + + +class UserSubscriptionCreateRequest(BaseModel): + """Схема для создания подписки через users API (user_id берется из URL)""" + is_trial: bool = False + duration_days: Optional[int] = None + traffic_limit_gb: Optional[int] = None + device_limit: Optional[int] = None + squad_uuid: Optional[str] = None + connected_squads: Optional[List[str]] = None + replace_existing: bool = False \ No newline at end of file diff --git a/app/webapi/schemas/webhooks.py b/app/webapi/schemas/webhooks.py new file mode 100644 index 00000000..40620031 --- /dev/null +++ b/app/webapi/schemas/webhooks.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +class WebhookCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + url: str = Field(..., min_length=1) + event_type: str = Field(..., min_length=1, max_length=50) + secret: Optional[str] = Field(default=None, max_length=128) + description: Optional[str] = Field(default=None) + + +class WebhookUpdateRequest(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=255) + url: Optional[str] = Field(default=None, min_length=1) + secret: Optional[str] = Field(default=None, max_length=128) + description: Optional[str] = None + is_active: Optional[bool] = None + + +class WebhookResponse(BaseModel): + id: int + name: str + url: str + event_type: str + is_active: bool + description: Optional[str] + created_at: datetime + updated_at: datetime + last_triggered_at: Optional[datetime] + failure_count: int + success_count: int + + class Config: + from_attributes = True + + +class WebhookListResponse(BaseModel): + items: list[WebhookResponse] + total: int + limit: int + offset: int + + +class WebhookDeliveryResponse(BaseModel): + id: int + webhook_id: int + event_type: str + payload: dict[str, Any] + response_status: Optional[int] + response_body: Optional[str] + status: str + error_message: Optional[str] + attempt_number: int + created_at: datetime + delivered_at: Optional[datetime] + next_retry_at: Optional[datetime] + + class Config: + from_attributes = True + + +class WebhookDeliveryListResponse(BaseModel): + items: list[WebhookDeliveryResponse] + total: int + limit: int + offset: int + + +class WebhookStatsResponse(BaseModel): + total_webhooks: int + active_webhooks: int + total_deliveries: int + successful_deliveries: int + failed_deliveries: int + success_rate: float + diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 3bc4e4e7..50375e50 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -14,6 +14,7 @@ from app.config import settings from app.services.payment_service import PaymentService from app.webapi.app import create_web_api_app from app.webapi.docs import add_redoc_endpoint +from app.cabinet.routes import router as cabinet_router from . import payments from . import telegram @@ -62,6 +63,19 @@ def _create_base_app() -> FastAPI: title="Bedolaga Unified Server", ) + # Add cabinet routes even when web API is disabled + if settings.is_cabinet_enabled(): + from fastapi.middleware.cors import CORSMiddleware + cabinet_origins = settings.get_cabinet_allowed_origins() + app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if "*" in cabinet_origins else cabinet_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(cabinet_router) + _attach_docs_alias(app, app.docs_url) return app diff --git a/docs/web-admin-integration-guide.md b/docs/web-admin-integration-guide.md new file mode 100644 index 00000000..f7bca51f --- /dev/null +++ b/docs/web-admin-integration-guide.md @@ -0,0 +1,1147 @@ +# Руководство по интеграции WebSocket и Webhooks в веб-админку + +## Содержание + +1. [Обзор](#обзор) +2. [Настройка WebSocket подключения](#настройка-websocket-подключения) +3. [Интеграция WebSocket в дашборд](#интеграция-websocket-в-дашборд) +4. [Управление Webhooks через API](#управление-webhooks-через-api) +5. [UI компоненты для Webhooks](#ui-компоненты-для-webhooks) +6. [Примеры реализации](#примеры-реализации) +7. [Обработка ошибок](#обработка-ошибок) +8. [Тестирование](#тестирование) + +--- + +## Обзор + +Веб-админка может использовать два механизма для получения обновлений: + +1. **WebSocket** - для real-time обновлений в интерфейсе (новые пользователи, платежи, тикеты) +2. **Webhooks** - для настройки внешних интеграций (отправка событий на внешние серверы) + +--- + +## Настройка WebSocket подключения + +### Шаг 1: Создать WebSocket менеджер + +Создайте утилиту для управления WebSocket подключением: + +```typescript +// utils/websocket.ts +class WebSocketManager { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private listeners: Map> = new Map(); + private apiToken: string; + + constructor(apiToken: string) { + this.apiToken = apiToken; + } + + connect(url: string): void { + if (this.ws?.readyState === WebSocket.OPEN) { + console.log('WebSocket already connected'); + return; + } + + const wsUrl = `${url}?token=${this.apiToken}`; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.reconnectAttempts = 0; + this.emit('connected', {}); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.emit('error', { error }); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + this.emit('disconnected', {}); + this.attemptReconnect(url); + }; + + // Ping для keepalive каждые 30 секунд + setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'ping' })); + } + }, 30000); + } + + private handleMessage(data: any): void { + if (data.type === 'pong') { + return; // Игнорируем pong + } + + if (data.type === 'connection') { + this.emit('connection', data); + return; + } + + // Эмитим событие по типу + this.emit(data.type, data.payload); + } + + private attemptReconnect(url: string): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Max reconnect attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + setTimeout(() => { + console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`); + this.connect(url); + }, delay); + } + + on(event: string, callback: Function): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: string, callback: Function): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.delete(callback); + } + } + + private emit(event: string, data: any): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in event listener for ${event}:`, error); + } + }); + } + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } +} + +export default WebSocketManager; +``` + +### Шаг 2: Инициализация в приложении + +```typescript +// App.tsx или main.tsx +import { useEffect, useState } from 'react'; +import WebSocketManager from './utils/websocket'; +import { getApiToken } from './utils/auth'; + +function App() { + const [wsManager, setWsManager] = useState(null); + + useEffect(() => { + const token = getApiToken(); + if (!token) { + console.warn('No API token found, WebSocket will not connect'); + return; + } + + const manager = new WebSocketManager(token); + const wsUrl = process.env.REACT_APP_WS_URL || 'ws://localhost:8080/ws'; + + manager.connect(wsUrl); + setWsManager(manager); + + // Обработка событий + manager.on('user.created', (payload) => { + console.log('New user created:', payload); + // Обновить список пользователей + // Показать уведомление + }); + + manager.on('payment.completed', (payload) => { + console.log('Payment completed:', payload); + // Обновить статистику + // Обновить баланс пользователя + }); + + manager.on('ticket.created', (payload) => { + console.log('New ticket created:', payload); + // Обновить список тикетов + // Показать уведомление + }); + + manager.on('ticket.status_changed', (payload) => { + console.log('Ticket status changed:', payload); + // Обновить статус тикета в списке + }); + + manager.on('ticket.message_added', (payload) => { + console.log('New message in ticket:', payload); + // Обновить список сообщений в тикете + // Показать уведомление о новом сообщении + }); + + return () => { + manager.disconnect(); + }; + }, []); + + return ( + // Ваш компонент приложения + ); +} +``` + +--- + +## Интеграция WebSocket в дашборд + +### Шаг 1: Создать React Hook для WebSocket + +```typescript +// hooks/useWebSocket.ts +import { useEffect, useState, useCallback } from 'react'; +import { useWebSocketContext } from '../contexts/WebSocketContext'; + +export function useWebSocketEvent(eventType: string) { + const { wsManager } = useWebSocketContext(); + const [data, setData] = useState(null); + + useEffect(() => { + if (!wsManager) return; + + const handler = (payload: T) => { + setData(payload); + }; + + wsManager.on(eventType, handler); + + return () => { + wsManager.off(eventType, handler); + }; + }, [wsManager, eventType]); + + return data; +} + +// Использование в компоненте +function Dashboard() { + const newUser = useWebSocketEvent('user.created'); + const newPayment = useWebSocketEvent('payment.completed'); + const newTicket = useWebSocketEvent('ticket.created'); + + useEffect(() => { + if (newUser) { + // Обновить счетчик пользователей + // Показать toast уведомление + } + }, [newUser]); + + return ( + // Ваш дашборд + ); +} +``` + +### Шаг 2: Обновление счетчиков в реальном времени + +```typescript +// components/DashboardStats.tsx +import { useState, useEffect } from 'react'; +import { useWebSocketContext } from '../contexts/WebSocketContext'; +import { fetchStats } from '../api/stats'; + +function DashboardStats() { + const { wsManager } = useWebSocketContext(); + const [stats, setStats] = useState({ + totalUsers: 0, + activeSubscriptions: 0, + openTickets: 0, + todayRevenue: 0, + }); + + // Загрузка начальных данных + useEffect(() => { + loadStats(); + }, []); + + // Подписка на события для обновления + useEffect(() => { + if (!wsManager) return; + + const updateOnNewUser = () => { + setStats(prev => ({ ...prev, totalUsers: prev.totalUsers + 1 })); + }; + + const updateOnPayment = (payload: any) => { + setStats(prev => ({ + ...prev, + todayRevenue: prev.todayRevenue + (payload.amount_rubles || 0), + })); + }; + + const updateOnTicket = () => { + setStats(prev => ({ ...prev, openTickets: prev.openTickets + 1 })); + }; + + wsManager.on('user.created', updateOnNewUser); + wsManager.on('payment.completed', updateOnPayment); + wsManager.on('ticket.created', updateOnTicket); + wsManager.on('ticket.message_added', updateOnTicketMessage); + + return () => { + wsManager.off('user.created', updateOnNewUser); + wsManager.off('payment.completed', updateOnPayment); + wsManager.off('ticket.created', updateOnTicket); + wsManager.off('ticket.message_added', updateOnTicketMessage); + }; + }, [wsManager]); + + const loadStats = async () => { + try { + const data = await fetchStats(); + setStats(data); + } catch (error) { + console.error('Failed to load stats:', error); + } + }; + + return ( +
+ + + + +
+ ); +} +``` + +### Шаг 3: Уведомления о новых событиях + +```typescript +// components/NotificationCenter.tsx +import { useState, useEffect } from 'react'; +import { useWebSocketContext } from '../contexts/WebSocketContext'; +import { toast } from 'react-toastify'; + +interface Notification { + id: string; + type: string; + message: string; + timestamp: Date; +} + +function NotificationCenter() { + const { wsManager } = useWebSocketContext(); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + if (!wsManager) return; + + const handleNewUser = (payload: any) => { + const notification: Notification = { + id: `user-${payload.user_id}`, + type: 'user.created', + message: `Новый пользователь: @${payload.username || payload.telegram_id}`, + timestamp: new Date(), + }; + addNotification(notification); + toast.info(notification.message); + }; + + const handleNewPayment = (payload: any) => { + const notification: Notification = { + id: `payment-${payload.transaction_id}`, + type: 'payment.completed', + message: `Пополнение баланса: ${payload.amount_rubles} ₽`, + timestamp: new Date(), + }; + addNotification(notification); + toast.success(notification.message); + }; + + const handleNewTicket = (payload: any) => { + const notification: Notification = { + id: `ticket-${payload.ticket_id}`, + type: 'ticket.created', + message: `Новый тикет: ${payload.title}`, + timestamp: new Date(), + }; + addNotification(notification); + toast.warning(notification.message, { + onClick: () => { + // Перейти к тикету + window.location.href = `/tickets/${payload.ticket_id}`; + }, + }); + }; + + const handleNewMessage = (payload: any) => { + const notification: Notification = { + id: `ticket-message-${payload.message_id}`, + type: 'ticket.message_added', + message: payload.is_from_admin + ? `Новый ответ в тикете #${payload.ticket_id}` + : `Новое сообщение от пользователя в тикете #${payload.ticket_id}`, + timestamp: new Date(), + }; + addNotification(notification); + toast.info(notification.message, { + onClick: () => { + // Перейти к тикету + window.location.href = `/tickets/${payload.ticket_id}`; + }, + }); + }; + + wsManager.on('user.created', handleNewUser); + wsManager.on('payment.completed', handleNewPayment); + wsManager.on('ticket.created', handleNewTicket); + wsManager.on('ticket.message_added', handleNewMessage); + + return () => { + wsManager.off('user.created', handleNewUser); + wsManager.off('payment.completed', handleNewPayment); + wsManager.off('ticket.created', handleNewTicket); + wsManager.off('ticket.message_added', handleNewMessage); + }; + }, [wsManager]); + + const addNotification = (notification: Notification) => { + setNotifications(prev => [notification, ...prev].slice(0, 50)); // Храним последние 50 + }; + + return ( +
+ {notifications.map(notif => ( + + ))} +
+ ); +} +``` + +--- + +## Управление Webhooks через API + +### Шаг 1: API клиент для webhooks + +```typescript +// api/webhooks.ts +import { apiClient } from './client'; + +export interface Webhook { + id: number; + name: string; + url: string; + event_type: string; + is_active: boolean; + description?: string; + created_at: string; + updated_at: string; + last_triggered_at?: string; + failure_count: number; + success_count: number; +} + +export interface WebhookCreateRequest { + name: string; + url: string; + event_type: string; + secret?: string; + description?: string; +} + +export interface WebhookUpdateRequest { + name?: string; + url?: string; + secret?: string; + description?: string; + is_active?: boolean; +} + +export const webhooksApi = { + // Список webhooks + list: async (params?: { + event_type?: string; + is_active?: boolean; + limit?: number; + offset?: number; + }): Promise<{ items: Webhook[]; total: number }> => { + const response = await apiClient.get('/webhooks', { params }); + return response.data; + }, + + // Получить webhook + get: async (id: number): Promise => { + const response = await apiClient.get(`/webhooks/${id}`); + return response.data; + }, + + // Создать webhook + create: async (data: WebhookCreateRequest): Promise => { + const response = await apiClient.post('/webhooks', data); + return response.data; + }, + + // Обновить webhook + update: async (id: number, data: WebhookUpdateRequest): Promise => { + const response = await apiClient.patch(`/webhooks/${id}`, data); + return response.data; + }, + + // Удалить webhook + delete: async (id: number): Promise => { + await apiClient.delete(`/webhooks/${id}`); + }, + + // Статистика + getStats: async (): Promise<{ + total_webhooks: number; + active_webhooks: number; + total_deliveries: number; + successful_deliveries: number; + failed_deliveries: number; + success_rate: number; + }> => { + const response = await apiClient.get('/webhooks/stats'); + return response.data; + }, + + // История доставок + getDeliveries: async ( + webhookId: number, + params?: { status?: string; limit?: number; offset?: number } + ): Promise<{ items: any[]; total: number }> => { + const response = await apiClient.get(`/webhooks/${webhookId}/deliveries`, { params }); + return response.data; + }, +}; +``` + +### Шаг 2: Список доступных типов событий + +```typescript +// constants/webhookEvents.ts +export const WEBHOOK_EVENT_TYPES = [ + { + value: 'user.created', + label: 'Создание пользователя', + description: 'Отправляется при регистрации нового пользователя', + }, + { + value: 'payment.completed', + label: 'Завершение платежа', + description: 'Отправляется при успешном пополнении баланса', + }, + { + value: 'transaction.created', + label: 'Создание транзакции', + description: 'Отправляется при создании любой транзакции', + }, + { + value: 'ticket.created', + label: 'Создание тикета', + description: 'Отправляется при создании нового тикета поддержки', + }, + { + value: 'ticket.status_changed', + label: 'Изменение статуса тикета', + description: 'Отправляется при изменении статуса тикета', + }, + { + value: 'ticket.message_added', + label: 'Новое сообщение в тикете', + description: 'Отправляется при добавлении нового сообщения в тикет (от пользователя или админа)', + }, +] as const; + +export type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number]['value']; +``` + +--- + +## UI компоненты для Webhooks + +### Шаг 1: Форма создания/редактирования webhook + +```typescript +// components/WebhookForm.tsx +import { useState } from 'react'; +import { webhooksApi, WebhookCreateRequest, WebhookUpdateRequest } from '../api/webhooks'; +import { WEBHOOK_EVENT_TYPES } from '../constants/webhookEvents'; + +interface WebhookFormProps { + webhook?: Webhook; + onSuccess: () => void; + onCancel: () => void; +} + +function WebhookForm({ webhook, onSuccess, onCancel }: WebhookFormProps) { + const [formData, setFormData] = useState({ + name: webhook?.name || '', + url: webhook?.url || '', + event_type: webhook?.event_type || '', + secret: '', + description: webhook?.description || '', + is_active: webhook?.is_active ?? true, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (webhook) { + await webhooksApi.update(webhook.id, formData); + } else { + await webhooksApi.create(formData); + } + onSuccess(); + } catch (err: any) { + setError(err.response?.data?.detail || 'Ошибка при сохранении webhook'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, url: e.target.value })} + required + placeholder="https://example.com/webhook" + /> +
+ +
+ + + {formData.event_type && ( + + {WEBHOOK_EVENT_TYPES.find((e) => e.value === formData.event_type)?.description} + + )} +
+ +
+ + setFormData({ ...formData, secret: e.target.value })} + placeholder="Для подписи payload" + /> + Если указан, payload будет подписан с помощью HMAC-SHA256 +
+ +
+ +