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:
@@ -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
|
||||||
|
"""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Cabinet services."""
|
||||||
|
|
||||||
|
from .email_service import EmailService, email_service
|
||||||
|
|
||||||
|
__all__ = ["EmailService", "email_service"]
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Email service for sending verification and password reset emails."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""Service for sending emails via SMTP."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.host = settings.SMTP_HOST
|
||||||
|
self.port = settings.SMTP_PORT
|
||||||
|
self.user = settings.SMTP_USER
|
||||||
|
self.password = settings.SMTP_PASSWORD
|
||||||
|
self.from_email = settings.get_smtp_from_email()
|
||||||
|
self.from_name = settings.SMTP_FROM_NAME
|
||||||
|
self.use_tls = settings.SMTP_USE_TLS
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""Check if SMTP is properly configured."""
|
||||||
|
return settings.is_smtp_configured()
|
||||||
|
|
||||||
|
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||||
|
"""Create and return SMTP connection."""
|
||||||
|
if self.use_tls:
|
||||||
|
smtp = smtplib.SMTP(self.host, self.port)
|
||||||
|
smtp.starttls()
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(self.host, self.port)
|
||||||
|
|
||||||
|
if self.user and self.password:
|
||||||
|
smtp.login(self.user, self.password)
|
||||||
|
|
||||||
|
return smtp
|
||||||
|
|
||||||
|
def send_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body_html: str,
|
||||||
|
body_text: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send an email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_email: Recipient email address
|
||||||
|
subject: Email subject
|
||||||
|
body_html: HTML body content
|
||||||
|
body_text: Plain text body (optional, generated from HTML if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.is_configured():
|
||||||
|
logger.warning("SMTP is not configured, cannot send email")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{self.from_name} <{self.from_email}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
|
||||||
|
# Plain text version
|
||||||
|
if body_text is None:
|
||||||
|
# Simple HTML to text conversion
|
||||||
|
import re
|
||||||
|
body_text = re.sub(r"<[^>]+>", "", body_html)
|
||||||
|
body_text = body_text.replace(" ", " ")
|
||||||
|
body_text = body_text.replace("&", "&")
|
||||||
|
body_text = body_text.replace("<", "<")
|
||||||
|
body_text = body_text.replace(">", ">")
|
||||||
|
|
||||||
|
part1 = MIMEText(body_text, "plain", "utf-8")
|
||||||
|
part2 = MIMEText(body_html, "html", "utf-8")
|
||||||
|
|
||||||
|
msg.attach(part1)
|
||||||
|
msg.attach(part2)
|
||||||
|
|
||||||
|
with self._get_smtp_connection() as smtp:
|
||||||
|
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||||
|
|
||||||
|
logger.info(f"Email sent successfully to {to_email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_verification_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
verification_token: str,
|
||||||
|
verification_url: str,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send email verification email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_email: Recipient email address
|
||||||
|
verification_token: Verification token
|
||||||
|
verification_url: Base URL for verification (token will be appended)
|
||||||
|
username: User's name for personalization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
full_url = f"{verification_url}?token={verification_token}"
|
||||||
|
greeting = f"Hello{', ' + username if username else ''}!"
|
||||||
|
|
||||||
|
subject = "Verify your email address"
|
||||||
|
body_html = f"""
|
||||||
|
<!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
@@ -494,6 +494,25 @@ class Settings(BaseSettings):
|
|||||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = 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')
|
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
|
def normalize_main_menu_mode(cls, value: Optional[str]) -> str:
|
||||||
@@ -1627,7 +1646,10 @@ class Settings(BaseSettings):
|
|||||||
return stars * self.get_stars_rate()
|
return stars * self.get_stars_rate()
|
||||||
|
|
||||||
def rubles_to_stars(self, rubles: float) -> int:
|
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]:
|
def get_admin_notifications_chat_id(self) -> Optional[int]:
|
||||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||||
@@ -2010,6 +2032,43 @@ class Settings(BaseSettings):
|
|||||||
raw_path = "miniapp"
|
raw_path = "miniapp"
|
||||||
return Path(raw_path)
|
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 = {
|
model_config = {
|
||||||
"env_file": ".env",
|
"env_file": ".env",
|
||||||
"env_file_encoding": "utf-8",
|
"env_file_encoding": "utf-8",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc, and_, or_, update, func
|
from sqlalchemy import select, desc, and_, or_, update, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -6,6 +7,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TicketCRUD:
|
class TicketCRUD:
|
||||||
"""CRUD операции для работы с тикетами"""
|
"""CRUD операции для работы с тикетами"""
|
||||||
@@ -47,6 +50,25 @@ class TicketCRUD:
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ticket)
|
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
|
return ticket
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -246,6 +268,24 @@ class TicketCRUD:
|
|||||||
ticket.closed_at = closed_at
|
ticket.closed_at = closed_at
|
||||||
|
|
||||||
await db.commit()
|
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
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -434,6 +474,26 @@ class TicketMessageCRUD:
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(message)
|
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
|
return message
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ async def create_transaction(
|
|||||||
|
|
||||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
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:
|
try:
|
||||||
from app.services.promo_group_assignment import (
|
from app.services.promo_group_assignment import (
|
||||||
maybe_assign_promo_group_by_total_spent,
|
maybe_assign_promo_group_by_total_spent,
|
||||||
|
|||||||
@@ -321,6 +321,26 @@ async def create_user(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
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
|
return user
|
||||||
|
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||||
from sqlalchemy import event, text, bindparam, inspect
|
from sqlalchemy import event, text, bindparam, inspect
|
||||||
|
from sqlalchemy.exc import ProgrammingError
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.exc import OperationalError, InterfaceError
|
from sqlalchemy.exc import OperationalError, InterfaceError
|
||||||
import time
|
import time
|
||||||
@@ -417,10 +418,46 @@ batch_ops = BatchOperations()
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
"""Инициализация БД с оптимизациями"""
|
"""Инициализация БД с оптимизациями"""
|
||||||
logger.info("Создание таблиц базы данных...")
|
logger.info("🚀 Создание таблиц базы данных...")
|
||||||
|
|
||||||
|
try:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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:
|
if not IS_SQLITE:
|
||||||
logger.info("Создание индексов для оптимизации...")
|
logger.info("Создание индексов для оптимизации...")
|
||||||
|
|||||||
@@ -665,6 +665,17 @@ class User(Base):
|
|||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
last_activity = Column(DateTime, default=func.now())
|
last_activity = Column(DateTime, default=func.now())
|
||||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
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")
|
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||||
@@ -688,6 +699,7 @@ class User(Base):
|
|||||||
promo_group = relationship("PromoGroup", back_populates="users")
|
promo_group = relationship("PromoGroup", back_populates="users")
|
||||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||||
poll_responses = relationship("PollResponse", back_populates="user")
|
poll_responses = relationship("PollResponse", back_populates="user")
|
||||||
|
notification_settings = Column(JSON, nullable=True, default=dict)
|
||||||
last_pinned_message_id = Column(Integer, nullable=True)
|
last_pinned_message_id = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Ограничения пользователя
|
# Ограничения пользователя
|
||||||
@@ -1939,3 +1951,92 @@ class ButtonClickLog(Base):
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
|
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}>"
|
||||||
@@ -1947,6 +1947,37 @@ async def ensure_user_promo_offer_discount_columns():
|
|||||||
return False
|
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:
|
async def ensure_promo_offer_template_active_duration_column() -> bool:
|
||||||
try:
|
try:
|
||||||
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
column_exists = await check_column_exists('promo_offer_templates', 'active_discount_hours')
|
||||||
@@ -5038,6 +5069,13 @@ async def run_universal_migration():
|
|||||||
else:
|
else:
|
||||||
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
|
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()
|
effect_types_updated = await migrate_discount_offer_effect_types()
|
||||||
if effect_types_updated:
|
if effect_types_updated:
|
||||||
logger.info("✅ Типы эффектов промо-предложений обновлены")
|
logger.info("✅ Типы эффектов промо-предложений обновлены")
|
||||||
@@ -5384,6 +5422,7 @@ async def check_migration_status():
|
|||||||
"users_promo_offer_discount_source_column": False,
|
"users_promo_offer_discount_source_column": False,
|
||||||
"users_promo_offer_discount_expires_column": False,
|
"users_promo_offer_discount_expires_column": False,
|
||||||
"users_referral_commission_percent_column": False,
|
"users_referral_commission_percent_column": False,
|
||||||
|
"users_notification_settings_column": False,
|
||||||
"subscription_crypto_link_column": False,
|
"subscription_crypto_link_column": False,
|
||||||
"subscription_modem_enabled_column": False,
|
"subscription_modem_enabled_column": False,
|
||||||
"subscription_purchased_traffic_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_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_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_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_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_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
|
||||||
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
|
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_source_column": "Колонка источника промо-скидки у пользователей",
|
||||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||||
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||||
|
"users_notification_settings_column": "Колонка notification_settings у пользователей",
|
||||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||||
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
|
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
|
||||||
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
|
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
@@ -37,8 +37,13 @@ from .routes import (
|
|||||||
transactions,
|
transactions,
|
||||||
users,
|
users,
|
||||||
logs,
|
logs,
|
||||||
|
webhooks,
|
||||||
|
websocket,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Cabinet (Personal Account) routes
|
||||||
|
from app.cabinet.routes import router as cabinet_router
|
||||||
|
|
||||||
|
|
||||||
OPENAPI_TAGS = [
|
OPENAPI_TAGS = [
|
||||||
{
|
{
|
||||||
@@ -145,6 +150,14 @@ OPENAPI_TAGS = [
|
|||||||
"name": "contests",
|
"name": "contests",
|
||||||
"description": "Управление конкурсами: реферальными и ежедневными играми/раундами.",
|
"description": "Управление конкурсами: реферальными и ежедневными играми/раундами.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "webhooks",
|
||||||
|
"description": "Управление webhooks для подписки на события системы (пользователи, платежи, тикеты).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "websocket",
|
||||||
|
"description": "WebSocket подключения для real-time обновлений дашборда и уведомлений.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pinned-messages",
|
"name": "pinned-messages",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -176,9 +189,12 @@ def create_web_api_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
allowed_origins = settings.get_web_api_allowed_origins()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
|
allow_origins=["*"] if "*" in all_origins else all_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -240,5 +256,11 @@ def create_web_api_app() -> FastAPI:
|
|||||||
prefix="/notifications/subscriptions",
|
prefix="/notifications/subscriptions",
|
||||||
tags=["notifications"],
|
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
|
return app
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ from app.database.crud.subscription import (
|
|||||||
add_subscription_traffic,
|
add_subscription_traffic,
|
||||||
create_paid_subscription,
|
create_paid_subscription,
|
||||||
create_trial_subscription,
|
create_trial_subscription,
|
||||||
|
deactivate_subscription,
|
||||||
extend_subscription,
|
extend_subscription,
|
||||||
get_subscription_by_user_id,
|
get_subscription_by_user_id,
|
||||||
replace_subscription,
|
replace_subscription,
|
||||||
remove_subscription_squad,
|
remove_subscription_squad,
|
||||||
)
|
)
|
||||||
|
from app.services.subscription_service import SubscriptionService
|
||||||
from app.database.models import Subscription, SubscriptionStatus
|
from app.database.models import Subscription, SubscriptionStatus
|
||||||
|
|
||||||
from ..dependencies import get_db_session, require_api_token
|
from ..dependencies import get_db_session, require_api_token
|
||||||
@@ -306,6 +308,30 @@ async def remove_subscription_squad_endpoint(
|
|||||||
return _serialize_subscription(subscription)
|
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)
|
@router.post("/{subscription_id}/modem", response_model=SubscriptionResponse)
|
||||||
async def set_subscription_modem(
|
async def set_subscription_modem(
|
||||||
subscription_id: int,
|
subscription_id: int,
|
||||||
|
|||||||
@@ -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)
|
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(
|
return TicketReplyResponse(
|
||||||
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
|
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
|
||||||
message=_serialize_message(message),
|
message=_serialize_message(message),
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
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.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 (
|
from app.database.crud.user import (
|
||||||
add_user_balance,
|
add_user_balance,
|
||||||
create_user,
|
create_user,
|
||||||
@@ -17,6 +25,7 @@ from app.database.crud.user import (
|
|||||||
update_user,
|
update_user,
|
||||||
)
|
)
|
||||||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
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 ..dependencies import get_db_session, require_api_token
|
||||||
from ..schemas.users import (
|
from ..schemas.users import (
|
||||||
@@ -26,6 +35,7 @@ from ..schemas.users import (
|
|||||||
UserCreateRequest,
|
UserCreateRequest,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
|
UserSubscriptionCreateRequest,
|
||||||
UserUpdateRequest,
|
UserUpdateRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -322,3 +332,149 @@ async def update_balance(
|
|||||||
found_user = await get_user_by_id(db, found_user.id)
|
found_user = await get_user_by_id(db, found_user.id)
|
||||||
|
|
||||||
return _serialize_user(found_user)
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -87,3 +87,14 @@ class BalanceUpdateRequest(BaseModel):
|
|||||||
amount_kopeks: int
|
amount_kopeks: int
|
||||||
description: Optional[str] = Field(default="Корректировка через веб-API")
|
description: Optional[str] = Field(default="Корректировка через веб-API")
|
||||||
create_transaction: bool = True
|
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
|
||||||
@@ -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,6 +14,7 @@ from app.config import settings
|
|||||||
from app.services.payment_service import PaymentService
|
from app.services.payment_service import PaymentService
|
||||||
from app.webapi.app import create_web_api_app
|
from app.webapi.app import create_web_api_app
|
||||||
from app.webapi.docs import add_redoc_endpoint
|
from app.webapi.docs import add_redoc_endpoint
|
||||||
|
from app.cabinet.routes import router as cabinet_router
|
||||||
|
|
||||||
from . import payments
|
from . import payments
|
||||||
from . import telegram
|
from . import telegram
|
||||||
@@ -62,6 +63,19 @@ def _create_base_app() -> FastAPI:
|
|||||||
title="Bedolaga Unified Server",
|
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)
|
_attach_docs_alias(app, app.docs_url)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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, так как эти функции наиболее востребованы для ежедневной работы администраторов.
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -35,6 +35,11 @@ pytz==2023.4
|
|||||||
cryptography>=41.0.0
|
cryptography>=41.0.0
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
|
|
||||||
|
# Личный кабинет (Cabinet)
|
||||||
|
bcrypt==4.2.0
|
||||||
|
PyJWT==2.8.0
|
||||||
|
email-validator==2.1.0
|
||||||
|
|
||||||
# Для работы с версиями
|
# Для работы с версиями
|
||||||
packaging==23.2
|
packaging==23.2
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -184,10 +184,20 @@ def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функ
|
|||||||
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None:
|
||||||
"""Позволяет запускать async def тесты без дополнительных плагинов."""
|
"""Позволяет запускать 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)
|
test_func = _unwrap_test(pyfuncitem.obj)
|
||||||
if not inspect.iscoroutinefunction(test_func):
|
if not inspect.iscoroutinefunction(test_func):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Проверяем, не обработан ли уже тест плагином pytest-asyncio
|
||||||
|
# Если pyfuncitem.obj не возвращает корутину - пропускаем
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop(loop)
|
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()
|
for name, value in pyfuncitem.funcargs.items()
|
||||||
if name in signature.parameters
|
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:
|
finally:
|
||||||
asyncio.set_event_loop(None)
|
asyncio.set_event_loop(None)
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
|||||||
lambda bot: admin_service_mock,
|
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()
|
bot = AsyncMock()
|
||||||
db_session = AsyncMock(spec=AsyncSession)
|
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,
|
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 = AsyncMock(spec=AsyncSession)
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
|
|
||||||
@@ -479,6 +491,12 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa
|
|||||||
lambda bot: admin_service_mock,
|
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 = AsyncMock(spec=AsyncSession)
|
||||||
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
|
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
|
||||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
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,
|
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 = AsyncMock(spec=AsyncSession)
|
||||||
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
|
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
|
||||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
||||||
@@ -690,6 +714,12 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
|
|||||||
lambda bot: admin_service_mock,
|
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 = AsyncMock(spec=AsyncSession)
|
||||||
db_session.commit = AsyncMock()
|
db_session.commit = AsyncMock()
|
||||||
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
|
||||||
|
|||||||
Reference in New Issue
Block a user