feat: add cabinet (personal account) backend API

- Add JWT authentication for cabinet users
- Add Telegram WebApp authentication
- Add subscription management endpoints
- Add balance and transactions endpoints
- Add referral system endpoints
- Add tickets support for cabinet
- Add webhooks and websocket for real-time updates
- Add email verification service

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
PEDZEO
2026-01-01 23:20:20 +03:00
parent ecaf270e04
commit 6b69ec750e
57 changed files with 10012 additions and 15 deletions
+10
View File
@@ -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
"""
+21
View File
@@ -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",
]
+64
View File
@@ -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
+106
View File
@@ -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)
+40
View File
@@ -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
+137
View File
@@ -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)
+140
View File
@@ -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
+43
View File
@@ -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"]
+421
View File
@@ -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}
+264
View File
@@ -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)
+450
View File
@@ -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,
)
+597
View File
@@ -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}
+316
View File
@@ -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,
)
+281
View File
@@ -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,
)
+387
View File
@@ -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),
)
+237
View File
@@ -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}
+145
View File
@@ -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,
}
+353
View File
@@ -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,
)
+306
View File
@@ -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"}
+77
View File
@@ -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,
)
+196
View File
@@ -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,
)
File diff suppressed because it is too large Load Diff
+264
View File
@@ -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)
+86
View File
@@ -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",
]
+90
View File
@@ -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
+62
View File
@@ -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
+72
View File
@@ -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
+105
View File
@@ -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
+71
View File
@@ -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")
+5
View File
@@ -0,0 +1,5 @@
"""Cabinet services."""
from .email_service import EmailService, email_service
__all__ = ["EmailService", "email_service"]
+225
View File
@@ -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("&nbsp;", " ")
body_text = body_text.replace("&amp;", "&")
body_text = body_text.replace("&lt;", "<")
body_text = body_text.replace("&gt;", ">")
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
<a href="{full_url}" class="button">Verify Email</a>
<p>Or copy and paste this link in your browser:</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #dc3545;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
.warning {{ color: #dc3545; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<h2>{greeting}</h2>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<a href="{full_url}" class="button">Reset Password</a>
<p>Or copy and paste this link in your browser:</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).</p>
<p class="warning">If you didn't request a password reset, please ignore this email or contact support if you're concerned.</p>
<div class="footer">
<p>Best regards,<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
+60 -1
View File
@@ -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",
+60
View File
@@ -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
+21
View File
@@ -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,
+20
View File
@@ -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:
+157
View File
@@ -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
+40 -3
View File
@@ -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("Создание индексов для оптимизации...")
+102 -1
View File
@@ -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"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
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"<Webhook id={self.id} name='{self.name}' event='{self.event_type}' status={status}>"
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"<WebhookDelivery id={self.id} webhook_id={self.webhook_id} status='{self.status}' event='{self.event_type}'>"
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"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
+41
View File
@@ -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",
+100
View File
@@ -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()
+167
View File
@@ -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()
+23 -1
View File
@@ -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
+34 -8
View File
@@ -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)
+19
View File
@@ -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),
+156
View File
@@ -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)
+229
View File
@@ -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,
)
+113
View File
@@ -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)
+11
View File
@@ -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
+81
View File
@@ -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
+14
View File
@@ -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
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
# Анализ существующих API и предложения по расширению
## Обзор существующих API эндпоинтов
### 1. Health & Monitoring (health.py)
- ✅ `GET /health` - статус API и версия бота
- ✅ `GET /health/database` - состояние БД
- ✅ `GET /metrics/pool` - метрики пула подключений
### 2. Statistics (stats.py)
- ✅ `GET /stats/overview` - общая статистика
- ✅ `GET /stats/full` - полная статистика с детализацией
### 3. Settings (config.py)
- ✅ `GET /settings/categories` - категории настроек
- ✅ `GET /settings` - список всех настроек
- ✅ `GET /settings/{key}` - получить настройку
- ✅ `PUT /settings/{key}` - обновить настройку
- ✅ `DELETE /settings/{key}` - сбросить настройку
### 4. Users (users.py)
- ✅ `GET /users` - список пользователей (с фильтрами)
- ✅ `GET /users/{id}` - детали пользователя (поддерживает telegram_id)
- ✅ `GET /users/by-telegram-id/{telegram_id}` - получить по Telegram ID
- ✅ `POST /users` - создать пользователя
- ✅ `PATCH /users/{id}` - обновить пользователя
- ✅ `POST /users/{id}/balance` - корректировка баланса
### 5. Subscriptions (subscriptions.py)
- ✅ `GET /subscriptions` - список подписок
- ✅ `GET /subscriptions/{id}` - детали подпискит
- ✅ `POST /subscriptions` - создать подписку
- ✅ `POST /subscriptions/{id}/extend` - продлить подписку
- ✅ `POST /subscriptions/{id}/traffic` - добавить трафик
- ✅ `POST /subscriptions/{id}/devices` - добавить устройства
- ✅ `POST /subscriptions/{id}/squads` - привязать сквад
- ✅ `DELETE /subscriptions/{id}/squads/{uuid}` - удалить сквад
### 6. Support/Tickets (tickets.py)
- ✅ `GET /tickets` - список тикетов
- ✅ `GET /tickets/{id}` - детали тикета с сообщениями
- ✅ `POST /tickets/{id}/status` - изменить статус
- ✅ `POST /tickets/{id}/priority` - изменить приоритет
- ✅ `POST /tickets/{id}/reply-block` - заблокировать ответы
- ✅ `DELETE /tickets/{id}/reply-block` - снять блокировку
- ✅ `POST /tickets/{id}/reply` - ответить на тикет
- ✅ `GET /tickets/{id}/messages/{message_id}/media` - получить медиа
### 7. Transactions (transactions.py)
- ✅ `GET /transactions` - история транзакций
### 8. Promo Groups (promo_groups.py)
- ✅ Полный CRUD для промо-групп
### 9. Promo Offers (promo_offers.py)
- ✅ Управление промо-предложениями, шаблонами и логами
### 10. Promocodes (promocodes.py)
- ✅ Полный CRUD для промокодов
### 11. Servers (servers.py)
- ✅ Управление серверами RemnaWave
### 12. RemnaWave Integration (remnawave.py)
- ✅ Статус, ноды, сквады, синхронизация
### 13. Contests (contests.py)
- ✅ Управление конкурсами (реферальные, ежедневные)
### 14. Campaigns (campaigns.py)
- ✅ Управление кампаниями
### 15. Broadcasts (broadcasts.py)
- ✅ `POST /broadcasts` - создать рассылку
- ✅ `GET /broadcasts` - список рассылок
- ✅ `POST /broadcasts/{id}/stop` - остановить рассылку
### 16. Menu Layout (menu_layout.py)
- ✅ Полное управление меню, статистика кликов, история
### 17. Main Menu (main_menu_buttons.py, user_messages.py)
- ✅ Управление кнопками и сообщениями главного меню
### 18. Welcome Texts (welcome_texts.py)
- ✅ CRUD для приветственных текстов
### 19. Pages (pages.py)
- ✅ Управление публичными страницами
### 20. Partners (partners.py)
- ✅ Статистика реферальной программы
### 21. Polls (polls.py)
- ✅ Управление опросами
### 22. Logs (logs.py)
- ✅ Логи мониторинга, поддержки и системные
### 23. Tokens (tokens.py)
- ✅ Управление токенами доступа
### 24. Media (media.py)
- ✅ Загрузка медиа
### 25. Miniapp (miniapp.py)
- ✅ Информация о подписке для Mini App
### 26. Subscription Events (subscription_events.py)
- ✅ Уведомления о событиях подписок
### 27. Support Settings (support_settings.py)
- ✅ Настройки поддержки
### 28. Backups (backups.py)
- ✅ Управление бэкапами
---
## Предложения по расширению API
### 🔴 Высокий приоритет
#### 1. Расширенная аналитика и отчеты
**Эндпоинты:**
- `GET /analytics/revenue` - доходы по периодам (день/неделя/месяц/год)
- `GET /analytics/users/growth` - динамика роста пользователей
- `GET /analytics/subscriptions/conversion` - конверсия триалов в платные
- `GET /analytics/churn` - анализ оттока пользователей
- `GET /analytics/retention` - анализ удержания пользователей
- `GET /analytics/export` - экспорт данных в CSV/Excel
**Польза:** Глубокая аналитика для принятия бизнес-решений
#### 2. Массовые операции с пользователями
**Эндпоинты:**
- `POST /users/batch/update` - массовое обновление пользователей
- `POST /users/batch/balance` - массовая корректировка балансов
- `POST /users/batch/status` - массовое изменение статусов
- `POST /users/batch/promo-group` - массовое назначение промо-групп
- `POST /users/export` - экспорт списка пользователей
**Польза:** Эффективное управление большими группами пользователей
#### 3. Расширенное управление подписками
**Эндпоинты:**
- `POST /subscriptions/{id}/pause` - приостановить подписку
- `POST /subscriptions/{id}/resume` - возобновить подписку
- `POST /subscriptions/{id}/cancel` - отменить подписку
- `POST /subscriptions/{id}/transfer` - перенести подписку другому пользователю
- `GET /subscriptions/{id}/history` - история изменений подписки
- `POST /subscriptions/batch/extend` - массовое продление подписок
- `GET /subscriptions/expiring` - подписки, истекающие в ближайшее время
**Польза:** Гибкое управление жизненным циклом подписок
#### 4. Улучшенная работа с тикетами
**Эндпоинты:**
- `GET /tickets/stats` - статистика по тикетам (среднее время ответа, распределение по приоритетам)
- `POST /tickets/{id}/assign` - назначить тикет модератору
- `GET /tickets/assigned/{admin_id}` - тикеты, назначенные модератору
- `POST /tickets/{id}/notes` - добавить внутреннюю заметку (не видна пользователю)
- `GET /tickets/{id}/notes` - получить заметки
- `POST /tickets/batch/close` - массовое закрытие тикетов
- `GET /tickets/search` - расширенный поиск по тикетам
**Польза:** Улучшенная организация работы поддержки
#### 5. Управление уведомлениями
**Эндпоинты:**
- `GET /notifications` - список всех уведомлений (расширенный)
- `GET /notifications/types` - типы уведомлений
- `POST /notifications/mark-read` - отметить как прочитанное
- `GET /notifications/unread-count` - количество непрочитанных
- `POST /notifications/settings` - настройки уведомлений для админа
**Польза:** Централизованное управление уведомлениями в админке
### 🟡 Средний приоритет
#### 6. Управление платежными системами
**Эндпоинты:**
- `GET /payments/methods` - список доступных методов оплаты
- `GET /payments/methods/{id}/stats` - статистика по методу оплаты
- `POST /payments/methods/{id}/toggle` - включить/выключить метод
- `GET /payments/failed` - список неудачных платежей
- `POST /payments/{id}/retry` - повторить платеж
- `GET /payments/refunds` - список возвратов
**Польза:** Мониторинг и управление платежами
#### 7. Автоматизация и задачи
**Эндпоинты:**
- `GET /automation/tasks` - список автоматических задач
- `POST /automation/tasks` - создать задачу
- `PATCH /automation/tasks/{id}` - обновить задачу
- `DELETE /automation/tasks/{id}` - удалить задачу
- `POST /automation/tasks/{id}/run` - запустить задачу вручную
- `GET /automation/tasks/{id}/history` - история выполнения
**Польза:** Автоматизация рутинных операций
#### 8. Управление контентом
**Эндпоинты:**
- `GET /content/templates` - список шаблонов сообщений
- `POST /content/templates` - создать шаблон
- `PATCH /content/templates/{id}` - обновить шаблон
- `DELETE /content/templates/{id}` - удалить шаблон
- `POST /content/templates/{id}/preview` - предпросмотр шаблона
- `GET /content/media-library` - библиотека медиа-файлов
- `POST /content/media-library` - загрузить в библиотеку
**Польза:** Централизованное управление контентом
#### 9. Аудит и безопасность
**Эндпоинты:**
- `GET /audit/logs` - журнал действий администраторов
- `GET /audit/logs/{admin_id}` - действия конкретного админа
- `GET /audit/sessions` - активные сессии API
- `POST /audit/sessions/{id}/revoke` - отозвать сессию
- `GET /security/events` - события безопасности
- `POST /security/block-ip` - заблокировать IP
- `GET /security/blocked-ips` - список заблокированных IP
**Польза:** Безопасность и отслеживание действий
#### 10. Интеграции и вебхуки
**Эндпоинты:**
- `GET /integrations` - список интеграций
- `POST /integrations` - создать интеграцию
- `PATCH /integrations/{id}` - обновить интеграцию
- `DELETE /integrations/{id}` - удалить интеграцию
- `GET /integrations/{id}/webhooks` - вебхуки интеграции
- `POST /integrations/{id}/webhooks` - создать вебхук
- `GET /integrations/{id}/logs` - логи интеграции
- `POST /integrations/{id}/test` - протестировать интеграцию
**Польза:** Расширение функциональности через интеграции
### 🟢 Низкий приоритет
#### 11. Экспорт и импорт данных
**Эндпоинты:**
- `POST /export/users` - экспорт пользователей
- `POST /export/subscriptions` - экспорт подписок
- `POST /export/transactions` - экспорт транзакций
- `POST /import/users` - импорт пользователей
- `GET /import/templates` - шаблоны для импорта
- `GET /import/{id}/status` - статус импорта
**Польза:** Работа с большими объемами данных
#### 12. Тестирование и разработка
**Эндпоинты:**
- `POST /dev/test-notification` - отправить тестовое уведомление
- `POST /dev/simulate-payment` - симулировать платеж
- `POST /dev/create-test-user` - создать тестового пользователя
- `GET /dev/test-endpoints` - список тестовых эндпоинтов
**Польза:** Упрощение разработки и тестирования
#### 13. Расширенная статистика по меню
**Эндпоинты:**
- `GET /menu-layout/stats/funnels` - воронки использования меню
- `GET /menu-layout/stats/heatmap` - тепловая карта кликов
- `GET /menu-layout/stats/ab-test` - A/B тестирование кнопок
**Польза:** Оптимизация UX меню
#### 14. Управление версиями и обновлениями
**Эндпоинты:**
- `GET /system/version` - версия системы
- `GET /system/updates` - доступные обновления
- `POST /system/updates/check` - проверить обновления
- `GET /system/changelog` - история изменений
**Польза:** Управление версиями бота
#### 15. Расширенная работа с промокодами
**Эндпоинты:**
- `GET /promo-codes/stats/usage` - статистика использования
- `POST /promo-codes/batch/create` - массовое создание
- `GET /promo-codes/{id}/users` - пользователи, использовавшие промокод
- `POST /promo-codes/{id}/deactivate` - деактивировать промокод
**Польза:** Более гибкое управление промокодами
---
## Рекомендации по приоритизации
### Фаза 1 (Следующие 2-4 недели)
1. Расширенная аналитика и отчеты (п.1)
2. Массовые операции с пользователями (п.2)
3. Расширенное управление подписками (п.3)
### Фаза 2 (Следующие 1-2 месяца)
4. Улучшенная работа с тикетами (п.4)
5. Управление уведомлениями (п.5)
6. Управление платежными системами (п.6)
### Фаза 3 (Долгосрочная перспектива)
7. Автоматизация и задачи (п.7)
8. Управление контентом (п.8)
9. Аудит и безопасность (п.9)
10. Интеграции и вебхуки (п.10)
---
## Технические улучшения
### 1. WebSocket поддержка
- Real-time обновления для дашборда
- Уведомления о новых тикетах
- Мониторинг рассылок в реальном времени
### 2. GraphQL endpoint
- Альтернатива REST для сложных запросов
- Более гибкая выборка данных
### 3. Rate limiting по токенам
- Разные лимиты для разных токенов
- Защита от злоупотреблений
### 4. Версионирование API
- `/v1/`, `/v2/` для обратной совместимости
- Плавная миграция на новые версии
### 5. Webhooks для событий
- Подписка на события (новый пользователь, платеж, тикет)
- Автоматические уведомления внешних систем
### 6. Расширенная документация
- Примеры использования для каждого эндпоинта
- Postman коллекция
- SDK для популярных языков (Python, JavaScript)
---
## Метрики для отслеживания использования API
### Предлагаемые эндпоинты для мониторинга:
- `GET /api/metrics/usage` - статистика использования API
- `GET /api/metrics/endpoints` - популярность эндпоинтов
- `GET /api/metrics/errors` - статистика ошибок
- `GET /api/metrics/performance` - производительность API
---
## Заключение
Текущий API уже достаточно функционален и покрывает основные потребности веб-админки. Предложенные расширения помогут:
1. **Улучшить аналитику** - для принятия обоснованных бизнес-решений
2. **Повысить эффективность** - массовые операции и автоматизация
3. **Улучшить UX** - более гибкое управление подписками и тикетами
4. **Повысить безопасность** - аудит и мониторинг
5. **Расширить возможности** - интеграции и вебхуки
Рекомендуется начать с Фазы 1, так как эти функции наиболее востребованы для ежедневной работы администраторов.
+307
View File
@@ -0,0 +1,307 @@
# WebSocket и Webhooks для веб-админки
## Обзор
Реализованы две системы для real-time обновлений и интеграций:
1. **WebSocket** - для real-time обновлений в веб-админке
2. **Webhooks** - для отправки событий во внешние системы
## WebSocket
### Подключение
WebSocket endpoint доступен по адресу: `ws://your-api-host:port/ws`
Для подключения требуется токен API (передается через query параметр):
```javascript
const ws = new WebSocket('ws://localhost:8080/ws?token=YOUR_API_TOKEN');
// или
const ws = new WebSocket('ws://localhost:8080/ws?api_key=YOUR_API_TOKEN');
```
### Формат сообщений
#### Входящие сообщения (от сервера)
```json
{
"type": "connection",
"status": "connected",
"message": "WebSocket connection established"
}
```
```json
{
"type": "user.created",
"payload": {
"user_id": 123,
"telegram_id": 456789,
"username": "testuser",
"first_name": "Test",
"last_name": "User",
"referral_code": "refABC123",
"referred_by_id": null
},
"timestamp": "2024-01-15T10:30:00"
}
```
#### Исходящие сообщения (от клиента)
**Ping для keepalive:**
```json
{
"type": "ping"
}
```
Сервер ответит:
```json
{
"type": "pong"
}
```
### Поддерживаемые события
- `user.created` - создан новый пользователь
- `payment.completed` - завершен платеж (пополнение баланса)
- `transaction.created` - создана транзакция
- `ticket.created` - создан новый тикет
- `ticket.status_changed` - изменен статус тикета
- `ticket.message_added` - добавлено новое сообщение в тикет (от пользователя или админа)
## Webhooks
### Создание webhook
```bash
POST /webhooks
Authorization: Bearer YOUR_API_TOKEN
Content-Type: application/json
{
"name": "My Webhook",
"url": "https://example.com/webhook",
"event_type": "user.created",
"secret": "optional-secret-for-signing",
"description": "Webhook для новых пользователей"
}
```
### Поддерживаемые типы событий
- `user.created` - создан новый пользователь
- `payment.completed` - завершен платеж
- `transaction.created` - создана транзакция
- `ticket.created` - создан новый тикет
- `ticket.status_changed` - изменен статус тикета
### Формат payload
Webhook отправляет POST запрос с JSON payload:
```json
{
"user_id": 123,
"telegram_id": 456789,
"username": "testuser",
"first_name": "Test",
"last_name": "User",
"referral_code": "refABC123",
"referred_by_id": null
}
```
### Заголовки запроса
- `Content-Type: application/json`
- `X-Webhook-Event: user.created` - тип события
- `X-Webhook-Id: 1` - ID webhook
- `X-Webhook-Signature: sha256=...` - подпись (если указан secret)
### Подпись payload
Если при создании webhook указан `secret`, payload подписывается с помощью HMAC-SHA256:
```python
import hmac
import hashlib
signature = hmac.new(
secret.encode('utf-8'),
payload_json.encode('utf-8'),
hashlib.sha256
).hexdigest()
```
Заголовок: `X-Webhook-Signature: sha256={signature}`
### Проверка подписи (пример на Python)
```python
import hmac
import hashlib
import json
def verify_webhook_signature(payload: dict, signature_header: str, secret: str) -> bool:
payload_json = json.dumps(payload, sort_keys=True)
expected_signature = hmac.new(
secret.encode('utf-8'),
payload_json.encode('utf-8'),
hashlib.sha256
).hexdigest()
received_signature = signature_header.replace('sha256=', '')
return hmac.compare_digest(expected_signature, received_signature)
```
### API эндпоинты
#### Список webhooks
```
GET /webhooks?event_type=user.created&is_active=true&limit=50&offset=0
```
#### Получить webhook
```
GET /webhooks/{webhook_id}
```
#### Обновить webhook
```
PATCH /webhooks/{webhook_id}
{
"name": "Updated Name",
"is_active": false
}
```
#### Удалить webhook
```
DELETE /webhooks/{webhook_id}
```
#### Статистика webhooks
```
GET /webhooks/stats
```
#### История доставок
```
GET /webhooks/{webhook_id}/deliveries?status=failed&limit=50&offset=0
```
### Статусы доставки
- `pending` - ожидает отправки
- `success` - успешно доставлен (HTTP 200-299)
- `failed` - ошибка доставки
### Retry логика
В текущей реализации retry не реализован автоматически, но можно добавить через `next_retry_at` поле в `WebhookDelivery`.
## Интеграция событий
События автоматически отправляются при:
1. **Создании пользователя** (`app/database/crud/user.py::create_user`)
2. **Создании транзакции** (`app/database/crud/transaction.py::create_transaction`)
3. **Создании тикета** (`app/database/crud/ticket.py::create_ticket`)
4. **Изменении статуса тикета** (`app/database/crud/ticket.py::update_ticket_status`)
## Примеры использования
### JavaScript WebSocket клиент
```javascript
const ws = new WebSocket('ws://localhost:8080/ws?token=YOUR_TOKEN');
ws.onopen = () => {
console.log('Connected');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Event:', data.type, data.payload);
if (data.type === 'user.created') {
// Обработка нового пользователя
updateDashboard(data.payload);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected');
};
// Ping для keepalive
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
```
### Python Webhook receiver
```python
from flask import Flask, request
import hmac
import hashlib
import json
app = Flask(__name__)
WEBHOOK_SECRET = "your-secret"
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Webhook-Signature', '')
event_type = request.headers.get('X-Webhook-Event')
payload = request.json
# Проверка подписи
if not verify_signature(payload, signature, WEBHOOK_SECRET):
return {'error': 'Invalid signature'}, 401
# Обработка события
if event_type == 'user.created':
handle_new_user(payload)
elif event_type == 'payment.completed':
handle_payment(payload)
return {'status': 'ok'}, 200
def verify_signature(payload, signature, secret):
payload_json = json.dumps(payload, sort_keys=True)
expected = hmac.new(
secret.encode(),
payload_json.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature.replace('sha256=', ''))
```
## Безопасность
1. **WebSocket**: Требует валидный API токен
2. **Webhooks**:
- Используйте HTTPS для webhook URL
- Используйте secret для подписи payload
- Проверяйте подпись на стороне получателя
- Ограничьте IP адреса получателей (если возможно)
## Мониторинг
- Проверяйте статистику webhooks через `/webhooks/stats`
- Просматривайте историю доставок через `/webhooks/{id}/deliveries`
- Мониторьте логи на наличие ошибок доставки
@@ -0,0 +1,100 @@
"""Add webhooks and webhook_deliveries tables"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
WEBHOOKS_TABLE = "webhooks"
DELIVERIES_TABLE = "webhook_deliveries"
def _table_exists(inspector: Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Создаем таблицу webhooks
if not _table_exists(inspector, WEBHOOKS_TABLE):
op.create_table(
WEBHOOKS_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("secret", sa.String(length=128), nullable=True),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_triggered_at", sa.DateTime(), nullable=True),
sa.Column("failure_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
)
op.create_index("ix_webhooks_event_type", WEBHOOKS_TABLE, ["event_type"])
op.create_index("ix_webhooks_is_active", WEBHOOKS_TABLE, ["is_active"])
# Создаем таблицу webhook_deliveries
if not _table_exists(inspector, DELIVERIES_TABLE):
op.create_table(
DELIVERIES_TABLE,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"webhook_id",
sa.Integer(),
sa.ForeignKey("webhooks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("event_type", sa.String(length=50), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("response_status", sa.Integer(), nullable=True),
sa.Column("response_body", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("attempt_number", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("delivered_at", sa.DateTime(), nullable=True),
sa.Column("next_retry_at", sa.DateTime(), nullable=True),
)
op.create_index(
"ix_webhook_deliveries_webhook_created",
DELIVERIES_TABLE,
["webhook_id", "created_at"],
)
op.create_index("ix_webhook_deliveries_status", DELIVERIES_TABLE, ["status"])
op.create_index("ix_webhook_deliveries_webhook_id", DELIVERIES_TABLE, ["webhook_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Удаляем таблицу webhook_deliveries
if _table_exists(inspector, DELIVERIES_TABLE):
op.drop_index("ix_webhook_deliveries_webhook_id", table_name=DELIVERIES_TABLE)
op.drop_index("ix_webhook_deliveries_status", table_name=DELIVERIES_TABLE)
op.drop_index(
"ix_webhook_deliveries_webhook_created",
table_name=DELIVERIES_TABLE,
)
op.drop_table(DELIVERIES_TABLE)
# Удаляем таблицу webhooks
if _table_exists(inspector, WEBHOOKS_TABLE):
op.drop_index("ix_webhooks_is_active", table_name=WEBHOOKS_TABLE)
op.drop_index("ix_webhooks_event_type", table_name=WEBHOOKS_TABLE)
op.drop_table(WEBHOOKS_TABLE)
+5
View File
@@ -35,6 +35,11 @@ pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2
# Личный кабинет (Cabinet)
bcrypt==4.2.0
PyJWT==2.8.0
email-validator==2.1.0
# Для работы с версиями
packaging==23.2
+15 -1
View File
@@ -184,10 +184,20 @@ def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функ
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
"""Позволяет запускать async def тесты без дополнительных плагинов."""
# Пропускаем если pytest-asyncio уже обработал этот тест
if hasattr(pyfuncitem, "_request") and hasattr(pyfuncitem._request, "_pyfuncitem"):
markers = list(pyfuncitem.iter_markers())
for marker in markers:
if marker.name in ("asyncio", "anyio"):
# pytest-asyncio обработает этот тест
return None
test_func = _unwrap_test(pyfuncitem.obj)
if not inspect.iscoroutinefunction(test_func):
return None
# Проверяем, не обработан ли уже тест плагином pytest-asyncio
# Если pyfuncitem.obj не возвращает корутину - пропускаем
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
@@ -197,7 +207,11 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
for name, value in pyfuncitem.funcargs.items()
if name in signature.parameters
}
loop.run_until_complete(pyfuncitem.obj(**call_kwargs))
coro = pyfuncitem.obj(**call_kwargs)
if coro is None:
# Уже обработано другим плагином
return None
loop.run_until_complete(coro)
finally:
asyncio.set_event_loop(None)
loop.close()
@@ -281,6 +281,12 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
lambda bot: admin_service_mock,
)
# Мок для get_subscription_by_user_id
monkeypatch.setattr(
"app.database.crud.subscription.get_subscription_by_user_id",
AsyncMock(return_value=subscription),
)
bot = AsyncMock()
db_session = AsyncMock(spec=AsyncSession)
@@ -372,6 +378,12 @@ async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch
lambda bot: admin_service_mock,
)
# Мок для get_subscription_by_user_id
monkeypatch.setattr(
"app.database.crud.subscription.get_subscription_by_user_id",
AsyncMock(return_value=subscription),
)
db_session = AsyncMock(spec=AsyncSession)
bot = AsyncMock()
@@ -479,6 +491,12 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa
lambda bot: admin_service_mock,
)
# Мок для get_subscription_by_user_id
monkeypatch.setattr(
"app.database.crud.subscription.get_subscription_by_user_id",
AsyncMock(return_value=subscription),
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
@@ -568,6 +586,12 @@ async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch):
lambda bot: admin_service_mock,
)
# Мок для get_subscription_by_user_id
monkeypatch.setattr(
"app.database.crud.subscription.get_subscription_by_user_id",
AsyncMock(return_value=subscription),
)
db_session = AsyncMock(spec=AsyncSession)
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
@@ -690,6 +714,12 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
lambda bot: admin_service_mock,
)
# Мок для get_subscription_by_user_id
monkeypatch.setattr(
"app.database.crud.subscription.get_subscription_by_user_id",
AsyncMock(return_value=subscription),
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock()
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh