Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2044cecc6e | |||
| ffffccb389 | |||
| 28d263fc8d | |||
| bfef7cc629 | |||
| a696896d2c | |||
| fd2e419e8e | |||
| aaf0263fda | |||
| d4d5031cc2 | |||
| b2d7abf5bd | |||
| 0f9f843236 | |||
| cab425cfac | |||
| 0da0c5547d | |||
| 988d0e5c2f | |||
| 1ce91749aa | |||
| 731eb24364 | |||
| a15403b8b6 | |||
| ff8f3d02cf | |||
| 69f57eddd6 | |||
| fe567fffa8 | |||
| f300e07ce2 | |||
| 628997fb48 | |||
| 3dc0b93bdf | |||
| bea9da96d4 | |||
| 388fc7ee67 | |||
| f6b6e22a95 | |||
| 60c4fe2e23 | |||
| c1da8a4dba | |||
| af6686ccfa | |||
| 8893fc128e | |||
| 4598c2785a | |||
| 5a7dd3f164 | |||
| bc7d0612f1 | |||
| 1646f04bde | |||
| 3fee54f657 | |||
| a594a0f79f | |||
| 3642462670 | |||
| 26efb157e4 |
@@ -630,6 +630,13 @@ FREEKASSA_WEBHOOK_PORT=8088
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
# Раздельные методы оплаты (отображаются как отдельные кнопки)
|
||||
# СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_ENABLED=false
|
||||
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
|
||||
# Карты РФ — i=36
|
||||
FREEKASSA_CARD_ENABLED=false
|
||||
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.18.0"
|
||||
".": "3.20.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [3.20.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.0...v3.20.1) (2026-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
|
||||
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
|
||||
|
||||
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
|
||||
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
|
||||
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
|
||||
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
|
||||
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
|
||||
|
||||
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
|
||||
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
|
||||
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
|
||||
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
|
||||
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
|
||||
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
|
||||
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
|
||||
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
|
||||
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
|
||||
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
|
||||
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
|
||||
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
|
||||
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
|
||||
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
|
||||
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
|
||||
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
|
||||
|
||||
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.18.0" # x-release-please-version
|
||||
ARG VERSION="v3.20.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -11,13 +11,23 @@ from app.config import settings
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
telegram_id: int | None = None,
|
||||
*,
|
||||
permissions: list[str] | None = None,
|
||||
roles: list[str] | None = None,
|
||||
role_level: int = 0,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
telegram_id: Telegram user ID (optional for email-only users)
|
||||
permissions: RBAC permission strings to embed in token
|
||||
roles: Role names to embed in token
|
||||
role_level: Maximum role level (0 = no special level)
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token
|
||||
@@ -36,6 +46,14 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
if telegram_id is not None:
|
||||
payload['telegram_id'] = telegram_id
|
||||
|
||||
# RBAC data — only include when provided to keep token compact
|
||||
if permissions is not None:
|
||||
payload['permissions'] = permissions
|
||||
if roles is not None:
|
||||
payload['roles'] = roles
|
||||
if role_level > 0:
|
||||
payload['role_level'] = role_level
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
+125
-9
@@ -224,27 +224,143 @@ async def get_optional_cabinet_user(
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user is admin by telegram_id or email.
|
||||
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
|
||||
**or** by RBAC role assignment (any role with level > 0).
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: Authenticated User object
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Authenticated admin User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not an admin
|
||||
HTTPException: If user is not an admin by either mechanism
|
||||
"""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
# Legacy check: config-based admin list
|
||||
is_legacy_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email if user.email_verified else None,
|
||||
)
|
||||
if is_legacy_admin:
|
||||
return user
|
||||
|
||||
return user
|
||||
# RBAC check: user has any active role with level > 0
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
|
||||
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
if max_level > 0:
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
|
||||
|
||||
def require_permission(*permissions: str):
|
||||
"""
|
||||
FastAPI dependency factory for RBAC permission checks.
|
||||
|
||||
Usage::
|
||||
|
||||
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
|
||||
async def list_users(...): ...
|
||||
|
||||
# Or inject the user:
|
||||
@router.get("/users")
|
||||
async def list_users(user: User = Depends(require_permission("users:read"))): ...
|
||||
"""
|
||||
if not permissions:
|
||||
raise ValueError('require_permission() requires at least one permission argument')
|
||||
|
||||
async def dependency(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
ip_address = (
|
||||
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.headers.get('X-Real-IP', '').strip()
|
||||
or (request.client.host if request.client else None)
|
||||
)
|
||||
user_agent = request.headers.get('user-agent', '')
|
||||
|
||||
# Extract resource_type from the first permission (section before ':')
|
||||
resource_type = None
|
||||
if permissions:
|
||||
first_perm = permissions[0]
|
||||
if ':' in first_perm:
|
||||
resource_type = first_perm.split(':', maxsplit=1)[0]
|
||||
|
||||
for perm in permissions:
|
||||
allowed, reason = await PermissionService.check_permission(
|
||||
db,
|
||||
user,
|
||||
perm,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
if not allowed:
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=perm,
|
||||
resource_type=resource_type,
|
||||
status='denied',
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details={'reason': reason},
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Permission denied: {reason}',
|
||||
)
|
||||
|
||||
# Capture request details
|
||||
details: dict = {
|
||||
'method': request.method,
|
||||
'path': str(request.url.path),
|
||||
}
|
||||
query_params = dict(request.query_params)
|
||||
if query_params:
|
||||
details['query_params'] = query_params
|
||||
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
try:
|
||||
body = await request.body()
|
||||
if body:
|
||||
import json
|
||||
|
||||
details['request_body'] = json.loads(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log successful access with all requested permissions
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=','.join(permissions),
|
||||
resource_type=resource_type,
|
||||
status='success',
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details=details,
|
||||
)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
@@ -13,9 +14,11 @@ from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
@@ -103,6 +106,9 @@ router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
router.include_router(admin_channels_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.database.models import User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -54,7 +54,7 @@ def _get_remnawave_config_uuid() -> str | None:
|
||||
|
||||
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
|
||||
async def get_remnawave_config_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""Get RemnaWave config integration status."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
@@ -67,7 +67,7 @@ async def get_remnawave_config_status(
|
||||
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
|
||||
async def set_remnawave_config_uuid(
|
||||
request: UpdateRemnaWaveUuidRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Set RemnaWave subscription config UUID."""
|
||||
@@ -102,7 +102,7 @@ async def set_remnawave_config_uuid(
|
||||
|
||||
@router.get('/remnawave/config')
|
||||
async def get_remnawave_subscription_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""Fetch subscription page config from RemnaWave panel."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
@@ -140,7 +140,7 @@ async def get_remnawave_subscription_config(
|
||||
|
||||
@router.get('/remnawave/configs')
|
||||
async def list_remnawave_subscription_configs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""List available subscription page configs from RemnaWave panel."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Admin audit log routes — view and export admin action history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AuditLogCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/audit-log', tags=['Admin RBAC Audit Log'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
"""Single audit log entry."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
action: str
|
||||
resource_type: str | None = None
|
||||
resource_id: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
status: str
|
||||
request_method: str | None = None
|
||||
request_path: str | None = None
|
||||
created_at: datetime | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""Paginated audit log list."""
|
||||
|
||||
items: list[AuditLogEntry]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============ CSV Export ============
|
||||
|
||||
_CSV_COLUMNS = [
|
||||
'id',
|
||||
'user_id',
|
||||
'action',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'status',
|
||||
'ip_address',
|
||||
'request_method',
|
||||
'request_path',
|
||||
'created_at',
|
||||
'user_agent',
|
||||
'details',
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_csv_cell(value: str) -> str:
|
||||
"""Prevent CSV formula injection by prefixing dangerous leading characters."""
|
||||
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
|
||||
return f"'{value}"
|
||||
return value
|
||||
|
||||
|
||||
def _logs_to_csv(logs) -> str:
|
||||
"""Serialize audit log entries to CSV string."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(_CSV_COLUMNS)
|
||||
|
||||
for log in logs:
|
||||
writer.writerow(
|
||||
[
|
||||
log.id,
|
||||
log.user_id,
|
||||
log.action,
|
||||
log.resource_type or '',
|
||||
log.resource_id or '',
|
||||
log.status,
|
||||
log.ip_address or '',
|
||||
log.request_method or '',
|
||||
_sanitize_csv_cell(log.request_path or ''),
|
||||
log.created_at.isoformat() if log.created_at else '',
|
||||
_sanitize_csv_cell((log.user_agent or '')[:200]),
|
||||
_sanitize_csv_cell(str(log.details) if log.details else ''),
|
||||
]
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AuditLogListResponse)
|
||||
async def list_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""List audit log entries with optional filters and pagination."""
|
||||
logs, total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
load_user=True,
|
||||
)
|
||||
|
||||
items = [
|
||||
AuditLogEntry(
|
||||
id=log.id,
|
||||
user_id=log.user_id,
|
||||
action=log.action,
|
||||
resource_type=log.resource_type,
|
||||
resource_id=log.resource_id,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent,
|
||||
status=log.status,
|
||||
request_method=log.request_method,
|
||||
request_path=log.request_path,
|
||||
created_at=log.created_at,
|
||||
user_first_name=log.user.first_name if log.user else None,
|
||||
user_email=log.user.email if log.user else None,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/export')
|
||||
async def export_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:export')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=10000, ge=1, le=50000),
|
||||
):
|
||||
"""Export audit logs as CSV file."""
|
||||
logs, _total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
csv_content = _logs_to_csv(logs)
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'audit_log_{timestamp}.csv'
|
||||
|
||||
logger.info(
|
||||
'Admin exported audit logs',
|
||||
admin_id=admin.id,
|
||||
rows=len(logs),
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -9,7 +9,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.ban_system import (
|
||||
BanAgentHistoryItem,
|
||||
BanAgentHistoryResponse,
|
||||
@@ -103,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
|
||||
|
||||
@router.get('/status', response_model=BanSystemStatusResponse)
|
||||
async def get_ban_system_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSystemStatusResponse:
|
||||
"""Get Ban System integration status."""
|
||||
return BanSystemStatusResponse(
|
||||
@@ -117,7 +117,7 @@ async def get_ban_system_status(
|
||||
|
||||
@router.get('/stats/raw')
|
||||
async def get_stats_raw(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> dict:
|
||||
"""Get raw stats from Ban System API for debugging."""
|
||||
api = _get_ban_api()
|
||||
@@ -127,7 +127,7 @@ async def get_stats_raw(
|
||||
|
||||
@router.get('/stats', response_model=BanSystemStatsResponse)
|
||||
async def get_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSystemStatsResponse:
|
||||
"""Get overall Ban System statistics."""
|
||||
from datetime import datetime
|
||||
@@ -181,7 +181,7 @@ async def get_users(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get list of users from Ban System."""
|
||||
api = _get_ban_api()
|
||||
@@ -211,7 +211,7 @@ async def get_users(
|
||||
@router.get('/users/over-limit', response_model=BanUsersListResponse)
|
||||
async def get_users_over_limit(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get users who exceeded their device limit."""
|
||||
api = _get_ban_api()
|
||||
@@ -241,7 +241,7 @@ async def get_users_over_limit(
|
||||
@router.get('/users/search/{query}')
|
||||
async def search_users(
|
||||
query: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Search for users."""
|
||||
api = _get_ban_api()
|
||||
@@ -272,7 +272,7 @@ async def search_users(
|
||||
@router.get('/users/{email}', response_model=BanUserDetailResponse)
|
||||
async def get_user_detail(
|
||||
email: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUserDetailResponse:
|
||||
"""Get detailed user information."""
|
||||
api = _get_ban_api()
|
||||
@@ -325,7 +325,7 @@ async def get_user_detail(
|
||||
|
||||
@router.get('/punishments', response_model=BanPunishmentsListResponse)
|
||||
async def get_punishments(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanPunishmentsListResponse:
|
||||
"""Get list of active punishments (bans)."""
|
||||
api = _get_ban_api()
|
||||
@@ -360,7 +360,7 @@ async def get_punishments(
|
||||
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
|
||||
async def unban_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:unban')),
|
||||
) -> UnbanResponse:
|
||||
"""Unban (enable) a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -377,7 +377,7 @@ async def unban_user(
|
||||
@router.post('/ban', response_model=UnbanResponse)
|
||||
async def ban_user(
|
||||
request: BanUserRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:ban')),
|
||||
) -> UnbanResponse:
|
||||
"""Manually ban a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -401,7 +401,7 @@ async def ban_user(
|
||||
async def get_punishment_history(
|
||||
query: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -438,7 +438,7 @@ async def get_punishment_history(
|
||||
|
||||
@router.get('/nodes', response_model=BanNodesListResponse)
|
||||
async def get_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanNodesListResponse:
|
||||
"""Get list of connected nodes."""
|
||||
api = _get_ban_api()
|
||||
@@ -480,7 +480,7 @@ async def get_agents(
|
||||
search: str | None = Query(None),
|
||||
health: str | None = Query(None, description='healthy, warning, critical'),
|
||||
agent_status: str | None = Query(None, alias='status', description='online, offline'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentsListResponse:
|
||||
"""Get list of monitoring agents."""
|
||||
api = _get_ban_api()
|
||||
@@ -579,7 +579,7 @@ async def get_agents(
|
||||
|
||||
@router.get('/agents/summary', response_model=BanAgentsSummary)
|
||||
async def get_agents_summary(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentsSummary:
|
||||
"""Get agents summary statistics."""
|
||||
api = _get_ban_api()
|
||||
@@ -603,7 +603,7 @@ async def get_agents_summary(
|
||||
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
|
||||
async def get_traffic_violations(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanTrafficViolationsResponse:
|
||||
"""Get list of traffic limit violations."""
|
||||
api = _get_ban_api()
|
||||
@@ -637,7 +637,7 @@ async def get_traffic_violations(
|
||||
|
||||
@router.get('/traffic', response_model=BanTrafficResponse)
|
||||
async def get_traffic(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanTrafficResponse:
|
||||
"""Get full traffic statistics including top users."""
|
||||
api = _get_ban_api()
|
||||
@@ -681,7 +681,7 @@ async def get_traffic(
|
||||
@router.get('/traffic/top')
|
||||
async def get_traffic_top(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> list[BanTrafficTopItem]:
|
||||
"""Get top users by traffic."""
|
||||
api = _get_ban_api()
|
||||
@@ -744,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
|
||||
|
||||
@router.get('/settings', response_model=BanSettingsResponse)
|
||||
async def get_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSettingsResponse:
|
||||
"""Get all Ban System settings."""
|
||||
api = _get_ban_api()
|
||||
@@ -802,7 +802,7 @@ async def get_settings(
|
||||
@router.get('/settings/{key}')
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Get a specific setting."""
|
||||
api = _get_ban_api()
|
||||
@@ -815,7 +815,7 @@ async def get_setting(
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: str = Query(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Set a setting value."""
|
||||
api = _get_ban_api()
|
||||
@@ -829,7 +829,7 @@ async def set_setting(
|
||||
@router.post('/settings/{key}/toggle')
|
||||
async def toggle_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Toggle a boolean setting."""
|
||||
api = _get_ban_api()
|
||||
@@ -846,7 +846,7 @@ async def toggle_setting(
|
||||
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
|
||||
async def whitelist_add(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> UnbanResponse:
|
||||
"""Add user to whitelist."""
|
||||
api = _get_ban_api()
|
||||
@@ -863,7 +863,7 @@ async def whitelist_add(
|
||||
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
|
||||
async def whitelist_remove(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> UnbanResponse:
|
||||
"""Remove user from whitelist."""
|
||||
api = _get_ban_api()
|
||||
@@ -883,7 +883,7 @@ async def whitelist_remove(
|
||||
@router.get('/report', response_model=BanReportResponse)
|
||||
async def get_report(
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanReportResponse:
|
||||
"""Get period report."""
|
||||
api = _get_ban_api()
|
||||
@@ -913,7 +913,7 @@ async def get_report(
|
||||
|
||||
@router.get('/health', response_model=BanHealthResponse)
|
||||
async def get_health(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHealthResponse:
|
||||
"""Get Ban System health status."""
|
||||
api = _get_ban_api()
|
||||
@@ -947,7 +947,7 @@ async def get_health(
|
||||
|
||||
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
|
||||
async def get_health_detailed(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHealthDetailedResponse:
|
||||
"""Get detailed health information."""
|
||||
api = _get_ban_api()
|
||||
@@ -967,7 +967,7 @@ async def get_health_detailed(
|
||||
async def get_agent_history(
|
||||
node_name: str,
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentHistoryResponse:
|
||||
"""Get agent statistics history."""
|
||||
api = _get_ban_api()
|
||||
@@ -1003,7 +1003,7 @@ async def get_agent_history(
|
||||
async def get_user_punishment_history(
|
||||
email: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a specific user."""
|
||||
api = _get_ban_api()
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastButton,
|
||||
BroadcastButtonsResponse,
|
||||
@@ -247,7 +247,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
|
||||
|
||||
@router.get('/filters', response_model=BroadcastFiltersResponse)
|
||||
async def get_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastFiltersResponse:
|
||||
"""Get all available filters with user counts."""
|
||||
@@ -310,7 +310,7 @@ async def get_filters(
|
||||
|
||||
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
|
||||
async def get_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastTariffsResponse:
|
||||
"""Get tariffs for broadcast filtering."""
|
||||
@@ -333,7 +333,7 @@ async def get_tariffs(
|
||||
|
||||
@router.get('/buttons', response_model=BroadcastButtonsResponse)
|
||||
async def get_buttons(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
) -> BroadcastButtonsResponse:
|
||||
"""Get available buttons for broadcasts."""
|
||||
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
|
||||
@@ -352,7 +352,7 @@ async def get_buttons(
|
||||
@router.post('/preview', response_model=BroadcastPreviewResponse)
|
||||
async def preview_broadcast(
|
||||
request: BroadcastPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastPreviewResponse:
|
||||
"""Preview broadcast recipients count."""
|
||||
@@ -381,7 +381,7 @@ async def preview_broadcast(
|
||||
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
request: BroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a broadcast."""
|
||||
@@ -461,7 +461,7 @@ async def create_broadcast(
|
||||
|
||||
@router.get('', response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -487,7 +487,7 @@ async def list_broadcasts(
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
@@ -523,7 +523,7 @@ async def get_email_filters(
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
@@ -548,7 +548,7 @@ async def preview_email_broadcast(
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
@@ -679,7 +679,7 @@ async def create_combined_broadcast(
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Get broadcast details."""
|
||||
@@ -695,7 +695,7 @@ async def get_broadcast(
|
||||
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.utils.button_styles_cache import (
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
@@ -145,7 +145,7 @@ async def get_button_styles(
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
@@ -243,7 +243,7 @@ async def update_button_styles(
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.campaigns import (
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
@@ -80,7 +80,7 @@ def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get campaigns overview statistics."""
|
||||
@@ -108,7 +108,7 @@ async def get_overview(
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerSquadInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available server squads for campaign subscription bonus."""
|
||||
@@ -126,7 +126,7 @@ async def get_available_servers(
|
||||
|
||||
@router.get('/available-tariffs', response_model=list[TariffListItem])
|
||||
async def get_available_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available tariffs for campaign tariff bonus."""
|
||||
@@ -155,7 +155,7 @@ async def get_available_tariffs(
|
||||
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
@@ -178,7 +178,7 @@ async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all campaigns."""
|
||||
@@ -211,7 +211,7 @@ async def list_campaigns(
|
||||
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign info."""
|
||||
@@ -257,7 +257,7 @@ async def get_campaign(
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:stats')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign statistics."""
|
||||
@@ -303,7 +303,7 @@ async def get_campaign_registrations(
|
||||
campaign_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users registered through campaign."""
|
||||
@@ -381,7 +381,7 @@ async def get_campaign_registrations(
|
||||
@router.post('', response_model=CampaignDetailResponse)
|
||||
async def create_new_campaign(
|
||||
request: CampaignCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new advertising campaign."""
|
||||
@@ -446,7 +446,7 @@ async def create_new_campaign(
|
||||
async def update_existing_campaign(
|
||||
campaign_id: int,
|
||||
request: CampaignUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing campaign."""
|
||||
@@ -532,7 +532,7 @@ async def update_existing_campaign(
|
||||
@router.delete('/{campaign_id}')
|
||||
async def delete_existing_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a campaign."""
|
||||
@@ -560,7 +560,7 @@ async def delete_existing_campaign(
|
||||
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
|
||||
async def toggle_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle campaign active status."""
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.database.crud.required_channel import (
|
||||
from app.database.models import User
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.channel import (
|
||||
ChannelCreateRequest,
|
||||
ChannelListResponse,
|
||||
@@ -31,7 +31,7 @@ router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin C
|
||||
@router.get('', response_model=ChannelListResponse)
|
||||
async def list_channels(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('channels:read')),
|
||||
) -> ChannelListResponse:
|
||||
channels = await get_all_channels(db)
|
||||
return ChannelListResponse(
|
||||
@@ -44,9 +44,16 @@ async def list_channels(
|
||||
async def create_channel(
|
||||
data: ChannelCreateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await add_channel(db, channel_id=data.channel_id, channel_link=data.channel_link, title=data.title)
|
||||
ch = await add_channel(
|
||||
db,
|
||||
channel_id=data.channel_id,
|
||||
channel_link=data.channel_link,
|
||||
title=data.title,
|
||||
disable_trial_on_leave=data.disable_trial_on_leave,
|
||||
disable_paid_on_leave=data.disable_paid_on_leave,
|
||||
)
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
@@ -56,7 +63,7 @@ async def update_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
data: ChannelUpdateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
ch = await update_channel(db, channel_db_id, **update_data)
|
||||
@@ -70,7 +77,7 @@ async def update_channel_endpoint(
|
||||
async def toggle_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await toggle_channel(db, channel_db_id)
|
||||
if not ch:
|
||||
@@ -83,7 +90,7 @@ async def toggle_channel_endpoint(
|
||||
async def delete_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> None:
|
||||
ok = await delete_channel(db, channel_db_id)
|
||||
if not ok:
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..services.email_template_overrides import (
|
||||
delete_template_override,
|
||||
get_all_overrides,
|
||||
@@ -370,7 +370,7 @@ class EmailTemplateSendTestRequest(BaseModel):
|
||||
|
||||
@router.get('', summary='List all email template types')
|
||||
async def list_template_types(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""List all available email template types with override status."""
|
||||
@@ -405,7 +405,7 @@ async def list_template_types(
|
||||
@router.get('/{notification_type}', summary='Get templates for a notification type')
|
||||
async def get_templates_for_type(
|
||||
notification_type: str,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Get all language templates for a specific notification type."""
|
||||
@@ -479,7 +479,7 @@ async def update_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
data: EmailTemplateUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Save a custom email template override."""
|
||||
@@ -515,7 +515,7 @@ async def update_template(
|
||||
async def reset_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete custom template override, reverting to default."""
|
||||
@@ -543,7 +543,7 @@ async def reset_template(
|
||||
async def preview_template(
|
||||
notification_type: str,
|
||||
data: EmailTemplatePreviewRequest,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview a rendered email template with sample data."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
@@ -588,7 +588,7 @@ async def preview_template(
|
||||
async def send_test_email(
|
||||
notification_type: str,
|
||||
data: EmailTemplateSendTestRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Send a test email to the admin's email address."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.database.models import (
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
@@ -73,7 +73,7 @@ def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
@@ -82,7 +82,7 @@ async def get_partner_settings(
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
from pathlib import Path
|
||||
@@ -159,7 +159,7 @@ async def list_applications(
|
||||
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
@@ -205,7 +205,7 @@ async def list_applications(
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
@@ -259,7 +259,7 @@ async def approve_application(
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
@@ -310,7 +310,7 @@ async def reject_application(
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
@@ -340,7 +340,7 @@ async def get_partner_stats(
|
||||
async def list_partners(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
@@ -404,7 +404,7 @@ async def list_partners(
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
@@ -460,7 +460,7 @@ async def get_partner_detail(
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
@@ -495,7 +495,7 @@ async def update_commission(
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:revoke')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
@@ -514,7 +514,7 @@ async def revoke_partner(
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
@@ -558,7 +558,7 @@ async def assign_campaign(
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.services.payment_method_config_service import (
|
||||
update_sort_order,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -124,7 +124,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
|
||||
|
||||
@router.get('', response_model=list[PaymentMethodConfigResponse])
|
||||
async def list_payment_methods(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all payment method configurations."""
|
||||
@@ -135,7 +135,7 @@ async def list_payment_methods(
|
||||
|
||||
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all promo groups for filter selector."""
|
||||
@@ -146,7 +146,7 @@ async def list_promo_groups(
|
||||
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
|
||||
async def get_payment_method(
|
||||
method_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a single payment method configuration."""
|
||||
@@ -163,7 +163,7 @@ async def get_payment_method(
|
||||
@router.put('/order')
|
||||
async def update_payment_methods_order(
|
||||
request: SortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Batch update sort order for payment methods."""
|
||||
@@ -176,7 +176,7 @@ async def update_payment_methods_order(
|
||||
async def update_payment_method(
|
||||
method_id: str,
|
||||
request: PaymentMethodConfigUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a payment method configuration."""
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.services.payment_verification_service import (
|
||||
run_manual_check,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -272,7 +272,7 @@ async def get_all_pending_payments(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all pending payments for admin verification."""
|
||||
@@ -306,7 +306,7 @@ async def get_all_pending_payments(
|
||||
|
||||
@router.get('/stats', response_model=PaymentsStatsResponse)
|
||||
async def get_payments_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get statistics about pending payments."""
|
||||
@@ -329,7 +329,7 @@ async def get_payments_stats(
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific pending payment."""
|
||||
@@ -356,7 +356,7 @@ async def get_pending_payment_details(
|
||||
async def check_payment_status(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Manually check and update payment status."""
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.services.pinned_message_service import (
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
@@ -89,7 +89,7 @@ def _get_bot() -> Bot:
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -117,7 +117,7 @@ async def list_pinned_messages(
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
@@ -130,7 +130,7 @@ async def get_active_message(
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
@@ -147,7 +147,7 @@ async def get_pinned_message(
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -201,7 +201,7 @@ async def create_pinned_message(
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
@@ -240,7 +240,7 @@ async def update_pinned_message(
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
@@ -267,7 +267,7 @@ async def update_pinned_message_settings(
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
@@ -282,7 +282,7 @@ async def deactivate_active_message(
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
@@ -311,7 +311,7 @@ async def unpin_active_message(
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -360,7 +360,7 @@ async def activate_pinned_message(
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
@@ -391,7 +391,7 @@ async def broadcast_message(
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Admin RBAC access policies management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/policies', tags=['Admin RBAC Policies'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PolicyResponse(BaseModel):
|
||||
"""Access policy response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
role_name: str | None = None
|
||||
priority: int
|
||||
effect: str
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PolicyCreateRequest(BaseModel):
|
||||
"""Create a new access policy."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int = Field(default=0, ge=0, le=1000)
|
||||
effect: str = Field(pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str = Field(min_length=1, max_length=100)
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyUpdateRequest(BaseModel):
|
||||
"""Update policy fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int | None = Field(default=None, ge=0, le=1000)
|
||||
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] | None = None
|
||||
resource: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
actions: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
|
||||
"""Convert AccessPolicy model to PolicyResponse with role name."""
|
||||
role_name = None
|
||||
if policy.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
|
||||
if role:
|
||||
role_name = role.name
|
||||
|
||||
return PolicyResponse(
|
||||
id=policy.id,
|
||||
name=policy.name,
|
||||
description=policy.description,
|
||||
role_id=policy.role_id,
|
||||
role_name=role_name,
|
||||
priority=policy.priority,
|
||||
effect=policy.effect,
|
||||
conditions=policy.conditions or {},
|
||||
resource=policy.resource,
|
||||
actions=policy.actions or [],
|
||||
is_active=policy.is_active,
|
||||
created_by=policy.created_by,
|
||||
created_at=policy.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=list[PolicyResponse])
|
||||
async def list_policies(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
role_id: int | None = None,
|
||||
):
|
||||
"""List all access policies. Optionally filter by role_id."""
|
||||
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
|
||||
return [await _policy_to_response(db, p) for p in policies]
|
||||
|
||||
|
||||
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_policy(
|
||||
payload: PolicyCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new access policy (ABAC rule)."""
|
||||
# Validate role_id if provided
|
||||
if payload.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
policy = await AccessPolicyCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
role_id=payload.role_id,
|
||||
priority=payload.priority,
|
||||
effect=payload.effect,
|
||||
conditions=payload.conditions,
|
||||
resource=payload.resource,
|
||||
actions=payload.actions,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin created access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy.id,
|
||||
policy_name=policy.name,
|
||||
effect=policy.effect,
|
||||
)
|
||||
return await _policy_to_response(db, policy)
|
||||
|
||||
|
||||
@router.put('/{policy_id}', response_model=PolicyResponse)
|
||||
async def update_policy(
|
||||
policy_id: int,
|
||||
payload: PolicyUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate role_id if changing
|
||||
if 'role_id' in update_data and update_data['role_id'] is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin updated access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
fields=list(update_data.keys()),
|
||||
)
|
||||
return await _policy_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/{policy_id}')
|
||||
async def delete_policy(
|
||||
policy_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete an access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
deleted = await AccessPolicyCRUD.delete(db, policy_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete policy',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin deleted access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
policy_name=existing.name,
|
||||
)
|
||||
return {'message': 'Policy deleted', 'policy_id': policy_id}
|
||||
@@ -34,7 +34,7 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -272,7 +272,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
|
||||
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
||||
async def list_templates(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
"""Get list of promo offer templates."""
|
||||
@@ -288,7 +288,7 @@ async def list_templates(
|
||||
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Get a promo offer template."""
|
||||
@@ -302,7 +302,7 @@ async def get_template(
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Update a promo offer template."""
|
||||
@@ -338,7 +338,7 @@ async def update_template(
|
||||
|
||||
@router.get('', response_model=PromoOfferListResponse)
|
||||
async def list_offers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -491,7 +491,7 @@ async def _send_promo_notifications(
|
||||
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def broadcast_offer(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
@@ -605,7 +605,7 @@ async def broadcast_offer(
|
||||
|
||||
@router.get('/logs', response_model=PromoOfferLogListResponse)
|
||||
async def get_logs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.database.crud.promocode import (
|
||||
)
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
|
||||
@@ -305,7 +305,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
|
||||
|
||||
@router.get('', response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -326,7 +326,7 @@ async def list_promocodes(
|
||||
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeDetailResponse:
|
||||
"""Get promocode details with usage statistics."""
|
||||
@@ -349,7 +349,7 @@ async def get_promocode(
|
||||
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Create a new promocode."""
|
||||
@@ -399,7 +399,7 @@ async def create_promocode_endpoint(
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Update an existing promocode."""
|
||||
@@ -460,7 +460,7 @@ async def update_promocode_endpoint(
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promocode."""
|
||||
@@ -486,7 +486,7 @@ class DeactivateDiscountResponse(BaseModel):
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
@@ -537,7 +537,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
|
||||
|
||||
@promo_groups_router.get('', response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -561,7 +561,7 @@ async def list_promo_groups(
|
||||
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Get promo group details."""
|
||||
@@ -576,7 +576,7 @@ async def get_promo_group(
|
||||
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Create a new promo group."""
|
||||
@@ -608,7 +608,7 @@ async def create_promo_group_endpoint(
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Update a promo group."""
|
||||
@@ -645,7 +645,7 @@ async def update_promo_group_endpoint(
|
||||
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promo group."""
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import User
|
||||
from app.utils.cache import cache
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.remnawave import (
|
||||
AutoSyncRunResponse,
|
||||
# Auto Sync
|
||||
@@ -153,7 +153,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
|
||||
@router.get('/status', response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
"""Get RemnaWave configuration and connection status."""
|
||||
service = _get_service()
|
||||
@@ -176,7 +176,7 @@ async def get_remnawave_status(
|
||||
|
||||
@router.get('/system', response_model=SystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> SystemStatsResponse:
|
||||
"""Get full system statistics from RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -238,7 +238,7 @@ async def get_system_statistics(
|
||||
|
||||
@router.get('/nodes', response_model=NodesListResponse)
|
||||
async def list_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodesListResponse:
|
||||
"""Get list of all nodes."""
|
||||
service = _get_service()
|
||||
@@ -252,7 +252,7 @@ async def list_nodes(
|
||||
|
||||
@router.get('/nodes/overview', response_model=NodesOverview)
|
||||
async def get_nodes_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodesOverview:
|
||||
"""Get nodes overview with statistics."""
|
||||
service = _get_service()
|
||||
@@ -278,7 +278,7 @@ async def get_nodes_overview(
|
||||
|
||||
@router.get('/nodes/realtime')
|
||||
async def get_nodes_realtime(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get realtime node usage data."""
|
||||
service = _get_service()
|
||||
@@ -290,7 +290,7 @@ async def get_nodes_realtime(
|
||||
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeInfo:
|
||||
"""Get detailed information about a specific node."""
|
||||
service = _get_service()
|
||||
@@ -309,7 +309,7 @@ async def get_node_details(
|
||||
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeStatisticsResponse:
|
||||
"""Get node statistics with usage history."""
|
||||
service = _get_service()
|
||||
@@ -335,7 +335,7 @@ async def get_node_usage(
|
||||
node_uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeUsageResponse:
|
||||
"""Get node usage history for a date range."""
|
||||
service = _get_service()
|
||||
@@ -358,7 +358,7 @@ async def get_node_usage(
|
||||
async def perform_node_action(
|
||||
node_uuid: str,
|
||||
payload: NodeActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Perform an action on a node (enable/disable/restart)."""
|
||||
service = _get_service()
|
||||
@@ -399,7 +399,7 @@ async def perform_node_action(
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
@@ -421,7 +421,7 @@ async def restart_all_nodes(
|
||||
|
||||
@router.get('/squads', response_model=SquadsListResponse)
|
||||
async def list_squads(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadsListResponse:
|
||||
"""Get list of all squads with local database info."""
|
||||
@@ -463,7 +463,7 @@ async def list_squads(
|
||||
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadDetailResponse:
|
||||
"""Get detailed information about a squad."""
|
||||
@@ -506,7 +506,7 @@ async def get_squad_details(
|
||||
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: SquadCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Create a new squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -533,7 +533,7 @@ async def create_squad(
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: SquadUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Update a squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -564,7 +564,7 @@ async def update_squad(
|
||||
async def perform_squad_action(
|
||||
squad_uuid: str,
|
||||
payload: SquadActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Perform an action on a squad."""
|
||||
service = _get_service()
|
||||
@@ -609,7 +609,7 @@ async def perform_squad_action(
|
||||
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
|
||||
async def delete_squad(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Delete a squad."""
|
||||
service = _get_service()
|
||||
@@ -632,7 +632,7 @@ async def delete_squad(
|
||||
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
|
||||
async def preview_migration(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationPreviewResponse:
|
||||
"""Get migration preview for a squad."""
|
||||
@@ -657,7 +657,7 @@ async def preview_migration(
|
||||
@router.post('/squads/migrate', response_model=MigrationResponse)
|
||||
async def migrate_squad_users(
|
||||
payload: MigrationRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationResponse:
|
||||
"""Migrate users from one squad to another."""
|
||||
@@ -731,7 +731,7 @@ async def migrate_squad_users(
|
||||
|
||||
@router.get('/inbounds', response_model=InboundsListResponse)
|
||||
async def list_inbounds(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> InboundsListResponse:
|
||||
"""Get list of all available inbounds."""
|
||||
service = _get_service()
|
||||
@@ -746,7 +746,7 @@ async def list_inbounds(
|
||||
|
||||
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
|
||||
async def get_auto_sync_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> AutoSyncStatus:
|
||||
"""Get auto sync status."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -775,7 +775,7 @@ async def get_auto_sync_status(
|
||||
@router.post('/sync/auto/toggle', response_model=SyncResponse)
|
||||
async def toggle_auto_sync(
|
||||
payload: AutoSyncToggleRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
) -> SyncResponse:
|
||||
"""Toggle auto sync on/off."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -811,7 +811,7 @@ async def toggle_auto_sync(
|
||||
|
||||
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
|
||||
async def run_auto_sync_now(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
) -> AutoSyncRunResponse:
|
||||
"""Run auto sync immediately."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -839,7 +839,7 @@ async def run_auto_sync_now(
|
||||
@router.post('/sync/from-panel', response_model=SyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: SyncMode,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from RemnaWave panel to bot."""
|
||||
@@ -863,7 +863,7 @@ async def sync_from_panel(
|
||||
|
||||
@router.post('/sync/to-panel', response_model=SyncResponse)
|
||||
async def sync_to_panel(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from bot to RemnaWave panel."""
|
||||
@@ -882,7 +882,7 @@ async def sync_to_panel(
|
||||
|
||||
@router.post('/sync/servers', response_model=SyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync servers/squads from RemnaWave."""
|
||||
@@ -925,7 +925,7 @@ async def sync_servers(
|
||||
|
||||
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
|
||||
async def validate_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Validate and fix subscriptions."""
|
||||
@@ -944,7 +944,7 @@ async def validate_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
|
||||
async def cleanup_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Cleanup orphaned subscriptions."""
|
||||
@@ -963,7 +963,7 @@ async def cleanup_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync subscription statuses."""
|
||||
@@ -982,7 +982,7 @@ async def sync_subscription_statuses(
|
||||
|
||||
@router.get('/sync/recommendations', response_model=SyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Get sync recommendations."""
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Admin RBAC roles management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac', tags=['Admin RBAC'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
"""Admin role with user count."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
level: int
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = None
|
||||
icon: str | None = None
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
user_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
"""Create a new custom role."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int = Field(ge=0, le=998)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
"""Update role fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int | None = Field(default=None, ge=0, le=998)
|
||||
permissions: list[str] | None = None
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RoleAssignRequest(BaseModel):
|
||||
"""Assign a role to a user."""
|
||||
|
||||
user_id: int
|
||||
role_id: int
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PermissionSection(BaseModel):
|
||||
"""Permission section with available actions."""
|
||||
|
||||
section: str
|
||||
actions: list[str]
|
||||
|
||||
|
||||
class UserRoleResponse(BaseModel):
|
||||
"""User-role assignment details."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
role_id: int
|
||||
role_name: str | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
assigned_by: int | None = None
|
||||
assigned_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminWithRolesResponse(BaseModel):
|
||||
"""User that has at least one admin role."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
"""Convert AdminRole model to RoleResponse with user count."""
|
||||
user_count = await AdminRoleCRUD.count_users(db, role.id)
|
||||
return RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
level=role.level,
|
||||
permissions=role.permissions or [],
|
||||
color=role.color,
|
||||
icon=role.icon,
|
||||
is_system=role.is_system,
|
||||
is_active=role.is_active,
|
||||
user_count=user_count,
|
||||
created_at=role.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the maximum role level of the current admin.
|
||||
|
||||
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
|
||||
so they can manage all roles including level 999.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, 1000)
|
||||
|
||||
return max_level
|
||||
|
||||
|
||||
def _validate_permissions(permissions: list[str]) -> None:
|
||||
"""Validate that all provided permissions exist in the registry."""
|
||||
all_valid = set(get_all_permissions())
|
||||
# Also allow wildcard patterns
|
||||
all_valid.add('*:*')
|
||||
for section in PERMISSION_REGISTRY:
|
||||
all_valid.add(f'{section}:*')
|
||||
|
||||
invalid = [p for p in permissions if p not in all_valid]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid permissions: {", ".join(invalid)}',
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/permissions', response_model=list[PermissionSection])
|
||||
async def get_permission_registry(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
):
|
||||
"""Get all available permissions grouped by section."""
|
||||
return [
|
||||
PermissionSection(section=section, actions=list(actions)) for section, actions in PERMISSION_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
|
||||
async def list_role_users(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List user-role assignments for a specific role."""
|
||||
from sqlalchemy.orm import selectinload as _sel
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Role not found')
|
||||
|
||||
from sqlalchemy import select as _sa_select
|
||||
|
||||
from app.database.models import UserRole as _UserRole
|
||||
|
||||
result = await db.execute(
|
||||
_sa_select(_UserRole)
|
||||
.options(_sel(_UserRole.user), _sel(_UserRole.role))
|
||||
.where(_UserRole.role_id == role_id, _UserRole.is_active.is_(True))
|
||||
.order_by(_UserRole.assigned_at.desc())
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
UserRoleResponse(
|
||||
id=a.id,
|
||||
user_id=a.user_id,
|
||||
role_id=a.role_id,
|
||||
role_name=a.role.name if a.role else None,
|
||||
user_telegram_id=a.user.telegram_id if a.user else None,
|
||||
user_username=a.user.username if a.user else None,
|
||||
user_first_name=a.user.first_name if a.user else None,
|
||||
user_email=a.user.email if a.user else None,
|
||||
assigned_by=a.assigned_by,
|
||||
assigned_at=a.assigned_at,
|
||||
expires_at=a.expires_at,
|
||||
is_active=a.is_active,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.get('/roles', response_model=list[RoleResponse])
|
||||
async def list_roles(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""List all admin roles with user counts."""
|
||||
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
|
||||
return [await _role_to_response(db, role) for role in roles]
|
||||
|
||||
|
||||
@router.post('/roles', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
payload: RoleCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new custom admin role."""
|
||||
# Validate permissions list
|
||||
_validate_permissions(payload.permissions)
|
||||
|
||||
# Hierarchy enforcement: cannot create role with level >= own level
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if payload.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot create a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Check name uniqueness
|
||||
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
level=payload.level,
|
||||
permissions=payload.permissions,
|
||||
color=payload.color,
|
||||
icon=payload.icon,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
|
||||
return await _role_to_response(db, role)
|
||||
|
||||
|
||||
@router.put('/roles/{role_id}', response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
payload: RoleUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing admin role."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot edit a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot edit a role at or above your own level',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot set role level >= your own role level',
|
||||
)
|
||||
|
||||
# Validate permissions
|
||||
if 'permissions' in update_data and update_data['permissions'] is not None:
|
||||
_validate_permissions(update_data['permissions'])
|
||||
|
||||
# Check name uniqueness if name is changing
|
||||
if 'name' in update_data and update_data['name'] != role.name:
|
||||
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
|
||||
return await _role_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/roles/{role_id}')
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a custom admin role. System roles cannot be deleted."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
if role.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a system role',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a role at or above your own level',
|
||||
)
|
||||
|
||||
deleted = await AdminRoleCRUD.delete(db, role_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete role',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
|
||||
return {'message': 'Role deleted', 'role_id': role_id}
|
||||
|
||||
|
||||
@router.post('/assignments', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def assign_role(
|
||||
payload: RoleAssignRequest,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a role to a user. Hierarchy enforcement applies."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
target_user = await get_user_by_id(db, payload.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Target user not found',
|
||||
)
|
||||
|
||||
user_role = await UserRoleCRUD.assign_role(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
assigned_by=admin.id,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin assigned role',
|
||||
admin_id=admin.id,
|
||||
target_user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return UserRoleResponse(
|
||||
id=user_role.id,
|
||||
user_id=user_role.user_id,
|
||||
role_id=user_role.role_id,
|
||||
role_name=role.name,
|
||||
user_telegram_id=target_user.telegram_id,
|
||||
user_username=target_user.username,
|
||||
user_first_name=target_user.first_name,
|
||||
user_email=target_user.email,
|
||||
assigned_by=user_role.assigned_by,
|
||||
assigned_at=user_role.assigned_at,
|
||||
expires_at=user_role.expires_at,
|
||||
is_active=user_role.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/assignments/{assignment_id}')
|
||||
async def revoke_role(
|
||||
assignment_id: int,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Load the assignment to check hierarchy
|
||||
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role assignment not found',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999)
|
||||
superadmin_level = 999
|
||||
if role.level == superadmin_level:
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
|
||||
if not revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to revoke role',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin revoked role assignment',
|
||||
admin_id=admin.id,
|
||||
assignment_id=assignment_id,
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.servers import (
|
||||
PromoGroupInfo,
|
||||
ServerDetailResponse,
|
||||
@@ -66,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
|
||||
@router.get('', response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers."""
|
||||
@@ -103,7 +103,7 @@ async def list_servers(
|
||||
@router.get('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed server info."""
|
||||
@@ -146,7 +146,7 @@ async def get_server(
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing server."""
|
||||
@@ -191,7 +191,7 @@ async def update_existing_server(
|
||||
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server availability."""
|
||||
@@ -218,7 +218,7 @@ async def toggle_server(
|
||||
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server trial eligibility."""
|
||||
@@ -245,7 +245,7 @@ async def toggle_server_trial(
|
||||
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get server statistics."""
|
||||
@@ -287,7 +287,7 @@ async def get_server_stats(
|
||||
|
||||
@router.post('/sync', response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync servers with RemnaWave."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.services.system_settings_service import (
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
|
||||
@router.get('/categories', response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Get list of setting categories."""
|
||||
categories = bot_configuration_service.get_categories()
|
||||
@@ -196,7 +196,7 @@ async def list_categories(
|
||||
|
||||
@router.get('', response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
category: str | None = Query(default=None, alias='category_key'),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
@@ -217,7 +217,7 @@ async def list_settings(
|
||||
@router.get('/{key}', response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Get a specific setting by key."""
|
||||
try:
|
||||
@@ -232,7 +232,7 @@ async def get_setting(
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a setting value."""
|
||||
@@ -255,7 +255,7 @@ async def update_setting(
|
||||
@router.delete('/{key}', response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset a setting to its default value."""
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.database.models import (
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -246,7 +246,7 @@ class RecentPaymentsResponse(BaseModel):
|
||||
|
||||
@router.get('/dashboard', response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get complete dashboard statistics for admin panel."""
|
||||
@@ -326,7 +326,7 @@ async def get_dashboard_stats(
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
@@ -358,7 +358,7 @@ async def get_system_info(
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
):
|
||||
"""Get status of all nodes."""
|
||||
try:
|
||||
@@ -374,7 +374,7 @@ async def get_nodes_status(
|
||||
@router.post('/nodes/{node_uuid}/restart')
|
||||
async def restart_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
):
|
||||
"""Restart a node."""
|
||||
try:
|
||||
@@ -401,7 +401,7 @@ async def restart_node(
|
||||
@router.post('/nodes/{node_uuid}/toggle')
|
||||
async def toggle_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
):
|
||||
"""Enable or disable a node."""
|
||||
try:
|
||||
@@ -596,7 +596,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
@router.get('/referrals/top', response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top referrers with earnings breakdown by period."""
|
||||
@@ -812,7 +812,7 @@ async def get_top_referrers(
|
||||
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
|
||||
async def get_top_campaigns(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top advertising campaigns with statistics."""
|
||||
@@ -869,7 +869,7 @@ async def get_top_campaigns(
|
||||
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
|
||||
async def get_recent_payments(
|
||||
limit: int = 50,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get recent payments with user info."""
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.database.crud.tariff import (
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
@@ -107,7 +107,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
|
||||
@router.get('', response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all tariffs."""
|
||||
@@ -141,7 +141,7 @@ async def list_tariffs(
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers for tariff selection."""
|
||||
@@ -161,7 +161,7 @@ async def get_available_servers(
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
@@ -176,7 +176,7 @@ async def update_tariff_order(
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed tariff info."""
|
||||
@@ -246,7 +246,7 @@ async def get_tariff(
|
||||
@router.post('', response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new tariff."""
|
||||
@@ -307,7 +307,7 @@ async def create_new_tariff(
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing tariff."""
|
||||
@@ -400,7 +400,7 @@ async def update_existing_tariff(
|
||||
@router.delete('/{tariff_id}')
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a tariff."""
|
||||
@@ -430,7 +430,7 @@ async def delete_existing_tariff(
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff active status."""
|
||||
@@ -460,7 +460,7 @@ async def toggle_tariff(
|
||||
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff trial availability.
|
||||
@@ -500,7 +500,7 @@ async def toggle_trial_tariff(
|
||||
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get tariff statistics."""
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
|
||||
|
||||
@router.get('/stats', response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket statistics."""
|
||||
@@ -222,7 +222,7 @@ async def get_ticket_stats(
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
@@ -242,7 +242,7 @@ async def get_ticket_settings(
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
@@ -337,7 +337,7 @@ async def get_all_tickets(
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all tickets for admin."""
|
||||
@@ -386,7 +386,7 @@ async def get_all_tickets(
|
||||
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
@@ -428,7 +428,7 @@ async def get_ticket_detail(
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:reply')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reply to a ticket as admin."""
|
||||
@@ -497,7 +497,7 @@ async def reply_to_ticket(
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
@@ -556,7 +556,7 @@ async def update_ticket_status(
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
@@ -262,7 +262,7 @@ def _build_traffic_items(
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -497,7 +497,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
@@ -530,7 +530,7 @@ async def get_traffic_enrichment(
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:export')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
from ..dependencies import require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
current_user: User = Depends(require_permission('updates:read')),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
|
||||
@@ -37,7 +37,7 @@ from app.database.models import (
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
@@ -297,7 +297,10 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
updated_panel_user = await api.update_user(**update_kwargs)
|
||||
subscription.subscription_url = updated_panel_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_panel_user.happ_crypto_link
|
||||
subscription.remnawave_short_uuid = updated_panel_user.short_uuid
|
||||
changes['action'] = 'updated'
|
||||
logger.info('Updated user in Remnawave panel', user_id=user.id)
|
||||
except Exception as update_error:
|
||||
@@ -326,6 +329,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
user.remnawave_uuid = new_panel_user.uuid
|
||||
subscription.remnawave_short_uuid = new_panel_user.short_uuid
|
||||
subscription.subscription_url = new_panel_user.subscription_url
|
||||
subscription.subscription_crypto_link = new_panel_user.happ_crypto_link
|
||||
changes['action'] = 'created'
|
||||
changes['panel_uuid'] = new_panel_user.uuid
|
||||
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
|
||||
@@ -351,7 +355,7 @@ async def list_users(
|
||||
email: str | None = Query(None, max_length=255),
|
||||
status: UserStatusEnum | None = Query(None),
|
||||
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -408,7 +412,7 @@ async def list_users(
|
||||
|
||||
@router.get('/stats', response_model=UsersStatsResponse)
|
||||
async def get_users_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall users statistics."""
|
||||
@@ -509,7 +513,7 @@ async def get_users_stats(
|
||||
@router.get('/{user_id}', response_model=UserDetailResponse)
|
||||
async def get_user_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed user information by ID."""
|
||||
@@ -640,7 +644,7 @@ async def get_user_detail(
|
||||
@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse)
|
||||
async def get_user_by_telegram(
|
||||
telegram_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user by Telegram ID."""
|
||||
@@ -659,7 +663,7 @@ async def get_user_by_telegram(
|
||||
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
|
||||
async def get_user_panel_info(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
|
||||
@@ -737,7 +741,7 @@ async def get_user_panel_info(
|
||||
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
|
||||
async def get_user_node_usage(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
|
||||
@@ -825,7 +829,7 @@ async def get_user_node_usage(
|
||||
async def update_user_balance(
|
||||
user_id: int,
|
||||
request: UpdateBalanceRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:balance')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -902,7 +906,7 @@ async def update_user_balance(
|
||||
async def update_user_subscription(
|
||||
user_id: int,
|
||||
request: UpdateSubscriptionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1269,7 +1273,7 @@ async def update_user_subscription(
|
||||
async def get_user_available_tariffs(
|
||||
user_id: int,
|
||||
include_inactive: bool = Query(False, description='Include inactive tariffs'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1367,7 +1371,7 @@ async def get_user_available_tariffs(
|
||||
async def update_user_status(
|
||||
user_id: int,
|
||||
request: UpdateUserStatusRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user status (active, blocked, deleted)."""
|
||||
@@ -1412,7 +1416,7 @@ async def update_user_status(
|
||||
async def block_user(
|
||||
user_id: int,
|
||||
reason: str | None = None,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Block a user (shortcut for status update)."""
|
||||
@@ -1423,7 +1427,7 @@ async def block_user(
|
||||
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
|
||||
async def unblock_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unblock a user (shortcut for status update)."""
|
||||
@@ -1438,7 +1442,7 @@ async def unblock_user(
|
||||
async def update_user_restrictions(
|
||||
user_id: int,
|
||||
request: UpdateRestrictionsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user restrictions (topup, subscription)."""
|
||||
@@ -1486,7 +1490,7 @@ async def update_user_restrictions(
|
||||
async def update_user_promo_group(
|
||||
user_id: int,
|
||||
request: UpdatePromoGroupRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:promo_group')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user promo group."""
|
||||
@@ -1541,7 +1545,7 @@ async def update_user_promo_group(
|
||||
async def update_user_referral_commission(
|
||||
user_id: int,
|
||||
request: UpdateReferralCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:referral')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
@@ -1579,7 +1583,7 @@ async def update_user_referral_commission(
|
||||
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
|
||||
async def get_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user devices from Remnawave panel."""
|
||||
@@ -1633,7 +1637,7 @@ async def get_user_devices(
|
||||
async def delete_user_device(
|
||||
user_id: int,
|
||||
hwid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a single device for user."""
|
||||
@@ -1664,7 +1668,7 @@ async def delete_user_device(
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
async def reset_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all devices for user."""
|
||||
@@ -1712,7 +1716,7 @@ async def reset_user_devices(
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
request: DeleteUserRequest = DeleteUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1750,7 +1754,7 @@ async def delete_user(
|
||||
async def full_delete_user(
|
||||
user_id: int,
|
||||
request: FullDeleteUserRequest = FullDeleteUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1799,7 +1803,7 @@ async def full_delete_user(
|
||||
async def reset_user_trial(
|
||||
user_id: int,
|
||||
request: ResetTrialRequest = ResetTrialRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1870,7 +1874,7 @@ async def reset_user_trial(
|
||||
async def reset_user_subscription(
|
||||
user_id: int,
|
||||
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1955,7 +1959,7 @@ async def reset_user_subscription(
|
||||
async def disable_user(
|
||||
user_id: int,
|
||||
request: DisableUserRequest = DisableUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -2032,7 +2036,7 @@ async def get_user_referrals(
|
||||
user_id: int,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users referred by this user."""
|
||||
@@ -2072,7 +2076,7 @@ async def get_user_transactions(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
transaction_type: str | None = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user transactions."""
|
||||
@@ -2129,7 +2133,7 @@ async def get_user_transactions(
|
||||
@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse)
|
||||
async def get_user_sync_status(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -2286,7 +2290,7 @@ async def get_user_sync_status(
|
||||
async def sync_user_from_panel(
|
||||
user_id: int,
|
||||
request: SyncFromPanelRequest = SyncFromPanelRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -2488,7 +2492,7 @@ async def sync_user_from_panel(
|
||||
async def sync_user_to_panel(
|
||||
user_id: int,
|
||||
request: SyncToPanelRequest = SyncToPanelRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminSpinItem,
|
||||
AdminSpinsResponse,
|
||||
@@ -42,7 +42,7 @@ router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
|
||||
|
||||
@router.get('/config', response_model=AdminWheelConfigResponse)
|
||||
async def get_admin_wheel_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить полную конфигурацию колеса."""
|
||||
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
|
||||
@router.put('/config', response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
|
||||
|
||||
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить список призов."""
|
||||
@@ -188,7 +188,7 @@ async def get_prizes(
|
||||
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prize(
|
||||
request: CreatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
@@ -237,7 +237,7 @@ async def create_prize(
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
@@ -286,7 +286,7 @@ async def update_prize(
|
||||
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prize_endpoint(
|
||||
prize_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
@@ -317,7 +317,7 @@ async def reorder_prizes(
|
||||
async def get_statistics(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить статистику колеса."""
|
||||
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
|
||||
date_to: datetime | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.database.models import (
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
@@ -49,7 +49,7 @@ async def list_withdrawals(
|
||||
),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
@@ -123,7 +123,7 @@ async def list_withdrawals(
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
@@ -180,7 +180,7 @@ async def get_withdrawal_detail(
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
@@ -232,7 +232,7 @@ async def approve_withdrawal(
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:reject')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
@@ -283,7 +283,7 @@ async def reject_withdrawal(
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_registration_by_user,
|
||||
)
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
@@ -99,9 +100,17 @@ def _user_to_response(user: User) -> UserResponse:
|
||||
)
|
||||
|
||||
|
||||
def _create_auth_response(user: User) -> AuthResponse:
|
||||
"""Create full auth response with tokens."""
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
async def _create_auth_response(user: User, db: AsyncSession) -> AuthResponse:
|
||||
"""Create full auth response with tokens and RBAC permissions."""
|
||||
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
|
||||
access_token = create_access_token(
|
||||
user.id,
|
||||
user.telegram_id,
|
||||
permissions=user_permissions,
|
||||
roles=user_role_names,
|
||||
role_level=user_role_level,
|
||||
)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
@@ -422,7 +431,7 @@ async def auth_telegram(
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
@@ -502,7 +511,7 @@ async def auth_telegram_widget(
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
@@ -777,7 +786,7 @@ async def verify_email(
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
|
||||
# Return auth tokens so user is logged in after verification
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
@@ -924,7 +933,7 @@ async def login_email(
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
@@ -987,7 +996,14 @@ async def refresh_token(
|
||||
detail='User not found or inactive',
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
access_token = create_access_token(
|
||||
user.id,
|
||||
user.telegram_id,
|
||||
permissions=user_permissions,
|
||||
roles=user_role_names,
|
||||
role_level=user_role_level,
|
||||
)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
return TokenResponse(
|
||||
@@ -1111,12 +1127,32 @@ async def get_current_user(
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.get('/me/permissions')
|
||||
async def get_my_permissions(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get current user's RBAC permissions, roles, and level."""
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
return await PermissionService.get_user_permissions(db, user.id, user=user)
|
||||
|
||||
|
||||
@router.get('/me/is-admin')
|
||||
async def check_is_admin(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Check if current user is an admin."""
|
||||
"""Check if current user is an admin (legacy config or RBAC)."""
|
||||
# Legacy check: config-based admin list
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
|
||||
if not is_admin:
|
||||
# RBAC check: user has any active role with level > 0
|
||||
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
if max_level > 0:
|
||||
is_admin = True
|
||||
|
||||
return {'is_admin': is_admin}
|
||||
|
||||
|
||||
|
||||
+174
-13
@@ -3,18 +3,19 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -37,6 +38,17 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
|
||||
|
||||
# Default animation config
|
||||
DEFAULT_ANIMATION_CONFIG = {
|
||||
'enabled': True,
|
||||
'type': 'aurora',
|
||||
'settings': {},
|
||||
'opacity': 1.0,
|
||||
'blur': 0,
|
||||
'reducedOnMobile': True,
|
||||
}
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -121,6 +133,92 @@ class AnimationEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
ALLOWED_BG_TYPES = (
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
)
|
||||
|
||||
MAX_SETTINGS_KEYS = 20
|
||||
MAX_SETTINGS_VALUE_LEN = 200
|
||||
|
||||
|
||||
def _validate_settings(v: dict) -> dict:
|
||||
"""Validate settings dict: flat structure, bounded size, no nested objects."""
|
||||
if len(v) > MAX_SETTINGS_KEYS:
|
||||
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
|
||||
for key, val in v.items():
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('Setting keys must be strings under 50 characters')
|
||||
if isinstance(val, dict | list):
|
||||
raise ValueError('Nested objects/arrays not allowed in settings')
|
||||
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
|
||||
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
|
||||
return v
|
||||
|
||||
|
||||
class AnimationConfigResponse(BaseModel):
|
||||
"""Full animation config."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = 'aurora'
|
||||
settings: dict = Field(default_factory=dict)
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
blur: float = Field(default=0, ge=0, le=100)
|
||||
reducedOnMobile: bool = True
|
||||
|
||||
|
||||
class AnimationConfigUpdate(BaseModel):
|
||||
"""Request to update animation config (partial update)."""
|
||||
|
||||
enabled: bool | None = None
|
||||
type: (
|
||||
Literal[
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
settings: dict | None = None
|
||||
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
blur: float | None = Field(default=None, ge=0, le=100)
|
||||
reducedOnMobile: bool | None = None
|
||||
|
||||
@field_validator('settings')
|
||||
@classmethod
|
||||
def validate_settings(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
return _validate_settings(v)
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
|
||||
@@ -296,7 +394,7 @@ async def get_logo():
|
||||
@router.put('/name', response_model=BrandingResponse)
|
||||
async def update_branding_name(
|
||||
payload: BrandingNameUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
|
||||
@@ -324,7 +422,7 @@ async def update_branding_name(
|
||||
@router.post('/logo', response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Upload a custom logo. Admin only."""
|
||||
@@ -387,7 +485,7 @@ async def upload_logo(
|
||||
|
||||
@router.delete('/logo', response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
@@ -459,7 +557,7 @@ async def get_theme_colors(
|
||||
@router.patch('/colors', response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update theme colors. Admin only. Partial update supported."""
|
||||
@@ -493,7 +591,7 @@ async def update_theme_colors(
|
||||
|
||||
@router.post('/colors/reset', response_model=ThemeColorsResponse)
|
||||
async def reset_theme_colors(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset theme colors to defaults. Admin only."""
|
||||
@@ -533,7 +631,7 @@ async def get_enabled_themes(
|
||||
@router.patch('/themes', response_model=EnabledThemesResponse)
|
||||
async def update_enabled_themes(
|
||||
payload: EnabledThemesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
|
||||
@@ -587,7 +685,7 @@ async def get_animation_enabled(
|
||||
@router.patch('/animation', response_model=AnimationEnabledResponse)
|
||||
async def update_animation_enabled(
|
||||
payload: AnimationEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
@@ -598,6 +696,69 @@ async def update_animation_enabled(
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Animation Config Routes (new JSON-based) ============
|
||||
|
||||
|
||||
@router.get('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def get_animation_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get full animation config. Public endpoint."""
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
|
||||
if config_value is not None:
|
||||
try:
|
||||
config = json.loads(config_value)
|
||||
return AnimationConfigResponse(**config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Auto-migrate from old ANIMATION_ENABLED_KEY
|
||||
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
if old_value is not None:
|
||||
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
|
||||
return AnimationConfigResponse(**config)
|
||||
|
||||
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
|
||||
@router.patch('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def update_animation_config(
|
||||
payload: AnimationConfigUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation config (partial update). Admin only."""
|
||||
# Get current config
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
if config_value:
|
||||
try:
|
||||
current = json.loads(config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
else:
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
# Merge only provided fields
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
current.update(update_data)
|
||||
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
|
||||
|
||||
# Also sync old key for backwards compat
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
|
||||
|
||||
logger.info(
|
||||
'Admin updated animation config',
|
||||
telegram_id=admin.telegram_id,
|
||||
type=current.get('type'),
|
||||
enabled=current.get('enabled'),
|
||||
)
|
||||
|
||||
return AnimationConfigResponse(**current)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
|
||||
@@ -622,7 +783,7 @@ async def get_fullscreen_enabled(
|
||||
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
@@ -658,7 +819,7 @@ async def get_email_auth_enabled(
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
async def update_email_auth_enabled(
|
||||
payload: EmailAuthEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update email auth enabled setting. Admin only."""
|
||||
@@ -694,7 +855,7 @@ async def get_analytics_counters(
|
||||
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
|
||||
async def update_analytics_counters(
|
||||
payload: AnalyticsCountersUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update analytics counter settings. Admin only. Partial update supported."""
|
||||
@@ -758,7 +919,7 @@ async def get_lite_mode_enabled(
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
|
||||
@@ -43,7 +43,7 @@ async def _finalize_oauth_login(
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
|
||||
@@ -204,7 +204,10 @@ def _subscription_to_response(
|
||||
if is_daily and not is_daily_paused:
|
||||
last_charge = getattr(subscription, 'last_daily_charge_at', None)
|
||||
if last_charge:
|
||||
next_daily_charge_at = last_charge + timedelta(days=1)
|
||||
next_charge = last_charge + timedelta(days=1)
|
||||
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
|
||||
if next_charge > datetime.now(UTC):
|
||||
next_daily_charge_at = next_charge
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -132,7 +132,7 @@ async def get_admin_notifications(
|
||||
unread_only: bool = Query(False, description='Only return unread notifications'),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket notifications for admins."""
|
||||
@@ -149,7 +149,7 @@ async def get_admin_notifications(
|
||||
|
||||
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
|
||||
async def get_admin_unread_count(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get unread notifications count for admins."""
|
||||
@@ -160,7 +160,7 @@ async def get_admin_unread_count(
|
||||
@admin_router.post('/{notification_id}/read')
|
||||
async def mark_admin_notification_as_read(
|
||||
notification_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark an admin notification as read."""
|
||||
@@ -185,7 +185,7 @@ async def mark_admin_notification_as_read(
|
||||
|
||||
@admin_router.post('/read-all')
|
||||
async def mark_all_admin_notifications_as_read(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications as read."""
|
||||
@@ -196,7 +196,7 @@ async def mark_all_admin_notifications_as_read(
|
||||
@admin_router.post('/ticket/{ticket_id}/read')
|
||||
async def mark_admin_ticket_notifications_as_read(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications for a specific ticket as read."""
|
||||
|
||||
@@ -282,7 +282,13 @@ async def add_ticket_message(
|
||||
|
||||
# Уведомить админов об ответе пользователя (Telegram)
|
||||
try:
|
||||
await notify_admins_about_ticket_reply(ticket, request.message, db)
|
||||
await notify_admins_about_ticket_reply(
|
||||
ticket,
|
||||
request.message,
|
||||
db,
|
||||
media_file_id=request.media_file_id,
|
||||
media_type=request.media_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ class ChannelResponse(BaseModel):
|
||||
title: str | None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
disable_trial_on_leave: bool
|
||||
disable_paid_on_leave: bool
|
||||
|
||||
|
||||
class ChannelListResponse(BaseModel):
|
||||
@@ -39,6 +41,8 @@ class ChannelCreateRequest(BaseModel):
|
||||
channel_id: str
|
||||
channel_link: str | None = None
|
||||
title: str | None = Field(None, max_length=255)
|
||||
disable_trial_on_leave: bool = True
|
||||
disable_paid_on_leave: bool = False
|
||||
|
||||
@field_validator('channel_id')
|
||||
@classmethod
|
||||
@@ -57,6 +61,8 @@ class ChannelUpdateRequest(BaseModel):
|
||||
title: str | None = Field(None, max_length=255)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
disable_trial_on_leave: bool | None = None
|
||||
disable_paid_on_leave: bool | None = None
|
||||
|
||||
@field_validator('channel_id')
|
||||
@classmethod
|
||||
|
||||
+27
-2
@@ -500,6 +500,11 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_USE_API: bool = False
|
||||
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
|
||||
SERVER_PUBLIC_IP: str | None = None
|
||||
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
|
||||
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
|
||||
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
|
||||
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
|
||||
|
||||
# KassaAI (api.fk.life) - отдельная платёжка
|
||||
KASSA_AI_ENABLED: bool = False
|
||||
@@ -1525,8 +1530,8 @@ class Settings(BaseSettings):
|
||||
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@@ -1750,6 +1755,26 @@ class Settings(BaseSettings):
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
|
||||
def is_freekassa_sbp_enabled(self) -> bool:
|
||||
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_sbp_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'СБП (QR код)'
|
||||
|
||||
def get_freekassa_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_sbp_display_name())
|
||||
|
||||
def is_freekassa_card_enabled(self) -> bool:
|
||||
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_card_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Карта РФ'
|
||||
|
||||
def get_freekassa_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_card_display_name())
|
||||
|
||||
def is_kassa_ai_enabled(self) -> bool:
|
||||
return (
|
||||
self.KASSA_AI_ENABLED
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import AccessPolicy, AdminAuditLog, AdminRole, User, UserRole
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields allowed for AdminRole.update()
|
||||
_ROLE_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'name',
|
||||
'description',
|
||||
'level',
|
||||
'permissions',
|
||||
'color',
|
||||
'icon',
|
||||
'is_active',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields allowed for AccessPolicy.update()
|
||||
_POLICY_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'name',
|
||||
'description',
|
||||
'role_id',
|
||||
'priority',
|
||||
'effect',
|
||||
'conditions',
|
||||
'resource',
|
||||
'actions',
|
||||
'is_active',
|
||||
}
|
||||
)
|
||||
|
||||
# Superadmin level constant
|
||||
_SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
class AdminRoleCRUD:
|
||||
"""CRUD operations for admin_roles table."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all(db: AsyncSession, *, include_inactive: bool = False) -> list[AdminRole]:
|
||||
"""Get all admin roles ordered by level descending."""
|
||||
stmt = select(AdminRole).order_by(AdminRole.level.desc())
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdminRole.is_active.is_(True))
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, role_id: int) -> AdminRole | None:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.id == role_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_name(db: AsyncSession, name: str) -> AdminRole | None:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: str | None,
|
||||
level: int,
|
||||
permissions: list[str],
|
||||
color: str | None = None,
|
||||
icon: str | None = None,
|
||||
is_system: bool = False,
|
||||
created_by: int | None = None,
|
||||
) -> AdminRole:
|
||||
role = AdminRole(
|
||||
name=name,
|
||||
description=description,
|
||||
level=level,
|
||||
permissions=permissions,
|
||||
color=color,
|
||||
icon=icon,
|
||||
is_system=is_system,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
await db.refresh(role)
|
||||
logger.info('Created admin role', role_id=role.id, name=name, level=level)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def update(db: AsyncSession, role_id: int, **kwargs: object) -> AdminRole | None:
|
||||
"""Update only provided fields. Rejects unknown/non-updatable keys."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ROLE_UPDATABLE_FIELDS:
|
||||
logger.warning('Rejected update of non-updatable AdminRole field', field=key)
|
||||
continue
|
||||
setattr(role, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(role)
|
||||
logger.info('Updated admin role', role_id=role_id, fields=list(kwargs.keys()))
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, role_id: int) -> bool:
|
||||
"""Delete a role. Returns False if the role is a system role or does not exist.
|
||||
|
||||
Cascades are handled by DB-level ON DELETE CASCADE on user_roles and access_policies.
|
||||
"""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
return False
|
||||
if role.is_system:
|
||||
logger.warning('Attempted to delete system role', role_id=role_id, name=role.name)
|
||||
return False
|
||||
|
||||
# Explicitly delete dependent user_roles and access_policies in application layer
|
||||
# to keep audit trail clear (DB cascade would also work, but explicit is better)
|
||||
await db.execute(delete(UserRole).where(UserRole.role_id == role_id))
|
||||
await db.execute(delete(AccessPolicy).where(AccessPolicy.role_id == role_id))
|
||||
await db.delete(role)
|
||||
await db.flush()
|
||||
logger.info('Deleted admin role', role_id=role_id, name=role.name)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def count_users(db: AsyncSession, role_id: int) -> int:
|
||||
"""Count active user_roles assigned to this role."""
|
||||
result = await db.execute(
|
||||
select(func.count(UserRole.id)).where(
|
||||
UserRole.role_id == role_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
class UserRoleCRUD:
|
||||
"""CRUD operations for user_roles table + permission aggregation."""
|
||||
|
||||
@staticmethod
|
||||
async def get_user_roles(db: AsyncSession, user_id: int) -> list[UserRole]:
|
||||
"""Get active user roles with eager-loaded AdminRole."""
|
||||
result = await db.execute(
|
||||
select(UserRole)
|
||||
.options(selectinload(UserRole.role))
|
||||
.where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_user_permissions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> tuple[list[str], list[str], int]:
|
||||
"""Aggregate permissions from all active, non-expired roles.
|
||||
|
||||
Returns:
|
||||
(sorted_permissions, role_names, max_level)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(UserRole)
|
||||
.options(selectinload(UserRole.role))
|
||||
.where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
user_roles = result.scalars().all()
|
||||
|
||||
permissions: set[str] = set()
|
||||
role_names: list[str] = []
|
||||
max_level: int = 0
|
||||
|
||||
for ur in user_roles:
|
||||
# Skip expired assignments
|
||||
if ur.expires_at is not None and ur.expires_at <= now:
|
||||
continue
|
||||
role = ur.role
|
||||
if role is None or not role.is_active:
|
||||
continue
|
||||
permissions.update(role.permissions or [])
|
||||
role_names.append(role.name)
|
||||
max_level = max(max_level, role.level)
|
||||
|
||||
return sorted(permissions), role_names, max_level
|
||||
|
||||
@staticmethod
|
||||
async def assign_role(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
role_id: int,
|
||||
assigned_by: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> UserRole:
|
||||
"""Assign a role to a user. Reactivates existing inactive assignment if present."""
|
||||
# Check for existing assignment (active or inactive) due to unique constraint
|
||||
result = await db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.role_id == role_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
existing.is_active = True
|
||||
existing.assigned_by = assigned_by
|
||||
existing.assigned_at = datetime.now(UTC)
|
||||
existing.expires_at = expires_at
|
||||
await db.flush()
|
||||
await db.refresh(existing)
|
||||
logger.info('Reactivated user role', user_role_id=existing.id, user_id=user_id, role_id=role_id)
|
||||
return existing
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
assigned_by=assigned_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(user_role)
|
||||
await db.flush()
|
||||
await db.refresh(user_role)
|
||||
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
|
||||
return user_role
|
||||
|
||||
@staticmethod
|
||||
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
|
||||
"""Soft-revoke: set is_active=False. Returns False if not found."""
|
||||
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
return False
|
||||
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_all_admins(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Get users that have at least one active role.
|
||||
|
||||
Returns list of dicts: [{'user': User, 'role_names': [str, ...]}]
|
||||
"""
|
||||
# Subquery: aggregate role names per user
|
||||
role_agg = (
|
||||
select(
|
||||
UserRole.user_id,
|
||||
func.array_agg(AdminRole.name).label('role_names'),
|
||||
)
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
)
|
||||
.group_by(UserRole.user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User, role_agg.c.role_names)
|
||||
.join(role_agg, User.id == role_agg.c.user_id)
|
||||
.order_by(User.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [{'user': row[0], 'role_names': list(row[1] or [])} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_superadmin_count(db: AsyncSession) -> int:
|
||||
"""Count users with an active role at superadmin level (999)."""
|
||||
result = await db.execute(
|
||||
select(func.count(func.distinct(UserRole.user_id)))
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
AdminRole.level == _SUPERADMIN_LEVEL,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
class AccessPolicyCRUD:
|
||||
"""CRUD operations for access_policies table (ABAC)."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
role_id: int | None = None,
|
||||
) -> list[AccessPolicy]:
|
||||
"""Get active policies ordered by priority descending. Optionally filter by role_id."""
|
||||
stmt = select(AccessPolicy).where(AccessPolicy.is_active.is_(True)).order_by(AccessPolicy.priority.desc())
|
||||
if role_id is not None:
|
||||
stmt = stmt.where(AccessPolicy.role_id == role_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, policy_id: int) -> AccessPolicy | None:
|
||||
result = await db.execute(select(AccessPolicy).where(AccessPolicy.id == policy_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(db: AsyncSession, **kwargs: object) -> AccessPolicy:
|
||||
policy = AccessPolicy(**kwargs)
|
||||
db.add(policy)
|
||||
await db.flush()
|
||||
await db.refresh(policy)
|
||||
logger.info('Created access policy', policy_id=policy.id, name=policy.name, effect=policy.effect)
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
async def update(db: AsyncSession, policy_id: int, **kwargs: object) -> AccessPolicy | None:
|
||||
"""Update only provided fields. Rejects unknown/non-updatable keys."""
|
||||
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not policy:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in _POLICY_UPDATABLE_FIELDS:
|
||||
logger.warning('Rejected update of non-updatable AccessPolicy field', field=key)
|
||||
continue
|
||||
setattr(policy, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(policy)
|
||||
logger.info('Updated access policy', policy_id=policy_id, fields=list(kwargs.keys()))
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, policy_id: int) -> bool:
|
||||
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not policy:
|
||||
return False
|
||||
await db.delete(policy)
|
||||
await db.flush()
|
||||
logger.info('Deleted access policy', policy_id=policy_id, name=policy.name)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_policies_for_user(
|
||||
db: AsyncSession,
|
||||
role_ids: list[int],
|
||||
) -> list[AccessPolicy]:
|
||||
"""Get active policies matching any of the given role_ids OR global (role_id IS NULL).
|
||||
|
||||
Ordered by priority descending for correct evaluation order.
|
||||
"""
|
||||
if not role_ids:
|
||||
# Only global policies
|
||||
stmt = (
|
||||
select(AccessPolicy)
|
||||
.where(
|
||||
AccessPolicy.is_active.is_(True),
|
||||
AccessPolicy.role_id.is_(None),
|
||||
)
|
||||
.order_by(AccessPolicy.priority.desc())
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(AccessPolicy)
|
||||
.where(
|
||||
AccessPolicy.is_active.is_(True),
|
||||
or_(
|
||||
AccessPolicy.role_id.in_(role_ids),
|
||||
AccessPolicy.role_id.is_(None),
|
||||
),
|
||||
)
|
||||
.order_by(AccessPolicy.priority.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class AuditLogCRUD:
|
||||
"""Create + filtered query for admin_audit_log table."""
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
status: str = 'success',
|
||||
request_method: str | None = None,
|
||||
request_path: str | None = None,
|
||||
) -> AdminAuditLog:
|
||||
entry = AdminAuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status=status,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
await db.refresh(entry)
|
||||
logger.debug(
|
||||
'Audit log created',
|
||||
audit_id=entry.id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
status=status,
|
||||
)
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
async def get_logs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
action: str | None = None,
|
||||
resource_type: str | None = None,
|
||||
status: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
load_user: bool = False,
|
||||
) -> tuple[list[AdminAuditLog], int]:
|
||||
"""Get filtered audit logs with total count.
|
||||
|
||||
Returns:
|
||||
(logs, total_count)
|
||||
"""
|
||||
filters = []
|
||||
if user_id is not None:
|
||||
filters.append(AdminAuditLog.user_id == user_id)
|
||||
if action is not None:
|
||||
filters.append(AdminAuditLog.action.ilike(f'%{action}%'))
|
||||
if resource_type is not None:
|
||||
filters.append(AdminAuditLog.resource_type == resource_type)
|
||||
if status is not None:
|
||||
filters.append(AdminAuditLog.status == status)
|
||||
if date_from is not None:
|
||||
filters.append(AdminAuditLog.created_at >= date_from)
|
||||
if date_to is not None:
|
||||
filters.append(AdminAuditLog.created_at <= date_to)
|
||||
|
||||
where_clause = and_(*filters) if filters else True
|
||||
|
||||
# Total count
|
||||
count_result = await db.execute(select(func.count(AdminAuditLog.id)).where(where_clause))
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
stmt = (
|
||||
select(AdminAuditLog)
|
||||
.where(where_clause)
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if load_user:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = stmt.options(selectinload(AdminAuditLog.user))
|
||||
result = await db.execute(stmt)
|
||||
logs = list(result.scalars().all())
|
||||
|
||||
return logs, total_count
|
||||
@@ -12,7 +12,17 @@ from app.database.models import RequiredChannel, UserChannelSubscription
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Explicit allowlist of fields that can be updated via update_channel()
|
||||
_UPDATABLE_FIELDS = frozenset({'channel_id', 'channel_link', 'title', 'is_active', 'sort_order'})
|
||||
_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'channel_id',
|
||||
'channel_link',
|
||||
'title',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'disable_trial_on_leave',
|
||||
'disable_paid_on_leave',
|
||||
}
|
||||
)
|
||||
|
||||
# Validation patterns for channel_id
|
||||
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
|
||||
@@ -69,12 +79,16 @@ async def add_channel(
|
||||
channel_id: str,
|
||||
channel_link: str | None = None,
|
||||
title: str | None = None,
|
||||
disable_trial_on_leave: bool = True,
|
||||
disable_paid_on_leave: bool = False,
|
||||
) -> RequiredChannel:
|
||||
channel_id = validate_channel_id(channel_id)
|
||||
channel = RequiredChannel(
|
||||
channel_id=channel_id,
|
||||
channel_link=channel_link,
|
||||
title=title,
|
||||
disable_trial_on_leave=disable_trial_on_leave,
|
||||
disable_paid_on_leave=disable_paid_on_leave,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.commit()
|
||||
|
||||
@@ -758,15 +758,23 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
|
||||
from app.database.models import Tariff
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(selectinload(Subscription.user))
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
# Не трогаем активные суточные подписки — ими управляет DailySubscriptionService
|
||||
~and_(
|
||||
Tariff.is_daily.is_(True),
|
||||
Subscription.is_daily_paused.is_(False),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -459,6 +459,19 @@ class TicketMessageCRUD:
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_first_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
|
||||
"""Получить первое сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
|
||||
@@ -28,6 +28,7 @@ from sqlalchemy import (
|
||||
TypeDecorator,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
@@ -1060,6 +1061,7 @@ class User(Base):
|
||||
promo_group = relationship('PromoGroup', back_populates='users')
|
||||
user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan')
|
||||
poll_responses = relationship('PollResponse', back_populates='user')
|
||||
admin_roles_rel = relationship('UserRole', foreign_keys='[UserRole.user_id]', back_populates='user')
|
||||
notification_settings = Column(JSON, nullable=True, default=dict)
|
||||
last_pinned_message_id = Column(Integer, nullable=True)
|
||||
|
||||
@@ -2823,6 +2825,8 @@ class RequiredChannel(Base):
|
||||
title = Column(String(255), nullable=True) # Display name
|
||||
is_active = Column(Boolean, nullable=False, server_default='true')
|
||||
sort_order = Column(Integer, nullable=False, server_default='0')
|
||||
disable_trial_on_leave = Column(Boolean, nullable=False, server_default='true')
|
||||
disable_paid_on_leave = Column(Boolean, nullable=False, server_default='false')
|
||||
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), nullable=True, onupdate=func.now())
|
||||
|
||||
@@ -2855,3 +2859,110 @@ class UserChannelSubscription(Base):
|
||||
f'<UserChannelSubscription telegram_id={self.telegram_id}'
|
||||
f' channel={self.channel_id!r} member={self.is_member}>'
|
||||
)
|
||||
|
||||
|
||||
# ── RBAC / ABAC models ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AdminRole(Base):
|
||||
"""Role definition with permission groups for admin cabinet RBAC."""
|
||||
|
||||
__tablename__ = 'admin_roles'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
level = Column(Integer, default=0, nullable=False)
|
||||
permissions = Column(JSONB, default=list, nullable=False)
|
||||
color = Column(String(7), nullable=True)
|
||||
icon = Column(String(50), nullable=True)
|
||||
is_system = Column(Boolean, default=False, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
user_roles = relationship('UserRole', back_populates='role')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<AdminRole id={self.id} name={self.name!r} level={self.level}>'
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""M2M assignment of users to admin roles."""
|
||||
|
||||
__tablename__ = 'user_roles'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False)
|
||||
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
assigned_at = Column(AwareDateTime(), server_default=func.now())
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
__table_args__ = (UniqueConstraint('user_id', 'role_id', name='uq_user_role'),)
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id], back_populates='admin_roles_rel')
|
||||
role = relationship('AdminRole', back_populates='user_roles')
|
||||
assigner = relationship('User', foreign_keys=[assigned_by])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<UserRole id={self.id} user_id={self.user_id} role_id={self.role_id}>'
|
||||
|
||||
|
||||
class AccessPolicy(Base):
|
||||
"""ABAC attribute-based access policy."""
|
||||
|
||||
__tablename__ = 'access_policies'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=True)
|
||||
priority = Column(Integer, default=0, nullable=False)
|
||||
effect = Column(String(10), nullable=False) # "allow" / "deny"
|
||||
conditions = Column(JSONB, default=dict, nullable=False)
|
||||
resource = Column(String(100), nullable=False)
|
||||
actions = Column(JSONB, default=list, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
role = relationship('AdminRole')
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<AccessPolicy id={self.id} name={self.name!r} effect={self.effect!r}>'
|
||||
|
||||
|
||||
class AdminAuditLog(Base):
|
||||
"""Immutable audit log for admin actions."""
|
||||
|
||||
__tablename__ = 'admin_audit_log'
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
action = Column(String(100), nullable=False)
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(String(100), nullable=True)
|
||||
details = Column(JSONB, nullable=True)
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(Text, nullable=True)
|
||||
status = Column(String(20), nullable=False)
|
||||
request_method = Column(String(10), nullable=True)
|
||||
request_path = Column(Text, nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_admin_audit_user_created', 'user_id', 'created_at'),
|
||||
Index('ix_admin_audit_resource', 'resource_type', 'resource_id'),
|
||||
Index('ix_admin_audit_created', 'created_at'),
|
||||
)
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<AdminAuditLog id={self.id} action={self.action!r} status={self.status!r}>'
|
||||
|
||||
Vendored
+11
-1
@@ -461,9 +461,19 @@ class RemnaWaveAPI:
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
logger.debug('Создание пользователя в панели', data=data)
|
||||
logger.info(
|
||||
'POST /api/users payload',
|
||||
username=data.get('username'),
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
uuid=user.uuid,
|
||||
response_hwidDeviceLimit=user.hwid_device_limit,
|
||||
)
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None:
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.filters import BaseFilter, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -32,6 +33,8 @@ from app.utils.currency_converter import currency_converter
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for Freekassa balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -18,12 +20,29 @@ from app.utils.decorators import error_handler
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
FREEKASSA_SUB_METHODS = {
|
||||
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
|
||||
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_freekassa_params(
|
||||
payment_method: str | None,
|
||||
) -> tuple[int | None, str]:
|
||||
"""Return (payment_system_id, display_name) for a freekassa sub-method key."""
|
||||
if payment_method and payment_method in FREEKASSA_SUB_METHODS:
|
||||
meta = FREEKASSA_SUB_METHODS[payment_method]
|
||||
return meta['payment_system_id'], meta['get_name']()
|
||||
return None, settings.get_freekassa_display_name()
|
||||
|
||||
|
||||
async def _create_freekassa_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Freekassa payment and sending response.
|
||||
@@ -34,10 +53,13 @@ async def _create_freekassa_payment_and_respond(
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
payment_method: Sub-method key (freekassa_sbp, freekassa_card, or None for default)
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
ps_id, display_name = _resolve_freekassa_params(payment_method)
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
@@ -53,6 +75,8 @@ async def _create_freekassa_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_system_id=ps_id,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -74,7 +98,6 @@ async def _create_freekassa_payment_and_respond(
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
@@ -103,7 +126,7 @@ async def _create_freekassa_payment_and_respond(
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
).format(name=html.escape(display_name), amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
@@ -128,9 +151,11 @@ async def process_freekassa_payment_amount(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -183,21 +208,44 @@ async def process_freekassa_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_topup(
|
||||
async def _start_freekassa_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start Freekassa top-up process - ask for amount.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка доступности метода
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
@@ -215,11 +263,11 @@ async def start_freekassa_topup(
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='freekassa')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
_, display_name = _resolve_freekassa_params(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -249,6 +297,39 @@ async def start_freekassa_topup(
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_card')
|
||||
|
||||
|
||||
FREEKASSA_PAYMENT_METHODS = {'freekassa', 'freekassa_sbp', 'freekassa_card'}
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_custom_amount(
|
||||
message: types.Message,
|
||||
@@ -260,7 +341,7 @@ async def process_freekassa_custom_amount(
|
||||
Process custom amount input for Freekassa payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'freekassa':
|
||||
if data.get('payment_method') not in FREEKASSA_PAYMENT_METHODS:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -285,19 +366,21 @@ async def process_freekassa_custom_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -308,7 +391,21 @@ async def process_freekassa_quick_amount(
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
@@ -363,4 +460,35 @@ async def process_freekassa_quick_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -113,11 +113,13 @@ async def route_payment_by_method(
|
||||
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'freekassa':
|
||||
if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'):
|
||||
from .freekassa import process_freekassa_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
await process_freekassa_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state, payment_method=payment_method
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'kassa_ai':
|
||||
@@ -838,10 +840,21 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import process_freekassa_quick_amount, start_freekassa_topup
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
||||
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
||||
|
||||
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from aiogram.filters import IS_MEMBER, IS_NOT_MEMBER, ChatMemberUpdatedFilter
|
||||
from aiogram.types import ChatMemberUpdated
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import deactivate_subscription, is_active_paid_subscription, reactivate_subscription
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SubscriptionStatus, UserStatus
|
||||
@@ -59,9 +59,6 @@ async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
return # Still missing some channels
|
||||
|
||||
# Reactivate subscription if it was disabled due to channel unsubscribe
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
@@ -125,7 +122,9 @@ async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
if settings.is_admin(user.id):
|
||||
return
|
||||
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
# Fetch per-channel settings to decide whether to disable
|
||||
channel_settings = await channel_subscription_service.get_channel_settings(channel_id)
|
||||
if not channel_settings:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -138,15 +137,8 @@ async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
return
|
||||
|
||||
# CHANNEL_REQUIRED_FOR_ALL: deactivate regardless of trial status
|
||||
# CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: only deactivate trial subscriptions
|
||||
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
pass # Deactivate any active subscription
|
||||
elif not subscription.is_trial:
|
||||
return # Not a trial -- skip
|
||||
|
||||
# Guard against paid subscriptions (user paid money, don't punish)
|
||||
if is_active_paid_subscription(subscription):
|
||||
# Per-channel settings: check if this channel requires deactivation
|
||||
if not channel_subscription_service.should_disable_subscription(channel_settings, subscription.is_trial):
|
||||
return
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
|
||||
@@ -65,16 +65,25 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int:
|
||||
|
||||
|
||||
def _get_user_period_discount(db_user: User, period_days: int) -> int:
|
||||
"""Получает скидку пользователя на период из промогруппы."""
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount = promo_group.get_discount_percent('period', period_days)
|
||||
if discount > 0:
|
||||
return discount
|
||||
"""Получает скидку пользователя на период из промогруппы + промо-оффер (стекинг).
|
||||
|
||||
Возвращает итоговый процент скидки после последовательного применения
|
||||
скидки промогруппы и персональной скидки промо-оффера.
|
||||
"""
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
group_discount = promo_group.get_discount_percent('period', period_days) if promo_group else 0
|
||||
personal_discount = get_user_active_promo_discount_percent(db_user)
|
||||
return personal_discount
|
||||
|
||||
if group_discount <= 0 and personal_discount <= 0:
|
||||
return 0
|
||||
|
||||
# Стекинг: применяем последовательно (как в кабинете)
|
||||
# price * (1 - group/100) * (1 - personal/100)
|
||||
# Вычисляем эффективный общий процент
|
||||
remaining = (100 - group_discount) * (100 - personal_discount)
|
||||
effective_discount = 100 - remaining // 100
|
||||
|
||||
return effective_discount
|
||||
|
||||
|
||||
def format_tariffs_list_text(
|
||||
|
||||
+35
-15
@@ -890,7 +890,9 @@ async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user
|
||||
|
||||
# Уведомить админов об ответе пользователя
|
||||
logger.info('Attempting to notify admins about ticket reply #', ticket_id=ticket_id)
|
||||
await notify_admins_about_ticket_reply(ticket, reply_text, db)
|
||||
await notify_admins_about_ticket_reply(
|
||||
ticket, reply_text, db, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error adding ticket reply', error=e)
|
||||
@@ -995,14 +997,11 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем язык пользователя для локализации заголовков в уведомлении
|
||||
# и формируем удобный текст уведомления для админов
|
||||
get_texts(settings.DEFAULT_LANGUAGE)
|
||||
title = (ticket.title or '').strip()
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
@@ -1011,6 +1010,18 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
|
||||
# Загружаем первое сообщение для получения медиа и превью текста
|
||||
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
message_preview = ''
|
||||
if first_message:
|
||||
media_file_id = first_message.media_file_id if first_message.has_media else None
|
||||
media_type = first_message.media_type if first_message.has_media else None
|
||||
msg_text = (first_message.message_text or '').strip()
|
||||
if msg_text:
|
||||
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
|
||||
|
||||
notification_text = (
|
||||
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
|
||||
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
|
||||
@@ -1018,13 +1029,13 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||||
f'📱 <b>Username:</b> @{username_display}\n'
|
||||
f'📝 <b>Заголовок:</b> {title or "—"}\n'
|
||||
f'📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||||
)
|
||||
|
||||
# Клавиатура с быстрыми действиями для админов в топике
|
||||
# Отправляем через общий сервис админ-уведомлений (поддерживает топики)
|
||||
# bot доступен из Dispatcher в middlewares; безопаснее взять из уже используемого контекста
|
||||
# Здесь используем lazy импорт из maintenance_service, где хранится бот
|
||||
if message_preview:
|
||||
notification_text += f'\n📩 <b>Сообщение:</b>\n{message_preview}\n'
|
||||
|
||||
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||||
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
bot = maintenance_service._bot or None
|
||||
@@ -1033,12 +1044,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_ticket_event_notification(notification_text, None)
|
||||
await service.send_ticket_event_notification(
|
||||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about new ticket', error=e)
|
||||
|
||||
|
||||
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
async def notify_admins_about_ticket_reply(
|
||||
ticket: Ticket,
|
||||
reply_text: str,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
media_file_id: str | None = None,
|
||||
media_type: str | None = None,
|
||||
):
|
||||
"""Уведомить админов об ответе пользователя на тикет"""
|
||||
logger.info('notify_admins_about_ticket_reply called for ticket #', ticket_id=ticket.id)
|
||||
try:
|
||||
@@ -1052,7 +1072,6 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Загрузим пользователя
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
@@ -1061,8 +1080,7 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
|
||||
# Обрезаем текст ответа для уведомления
|
||||
reply_preview = reply_text[:150] + '...' if len(reply_text) > 150 else reply_text
|
||||
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
|
||||
|
||||
notification_text = (
|
||||
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
|
||||
@@ -1082,7 +1100,9 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
result = await service.send_ticket_event_notification(notification_text, None)
|
||||
result = await service.send_ticket_event_notification(
|
||||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
logger.info('Ticket # reply notification sent', ticket_id=ticket.id, result=result)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply', error=e)
|
||||
|
||||
+29
-1
@@ -1569,7 +1569,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
if settings.is_freekassa_sbp_enabled():
|
||||
sbp_name = settings.get_freekassa_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_FREEKASSA_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('freekassa_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_freekassa_card_enabled():
|
||||
card_name = settings.get_freekassa_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_FREEKASSA_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('freekassa_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_freekassa_enabled()
|
||||
and not settings.is_freekassa_sbp_enabled()
|
||||
and not settings.is_freekassa_card_enabled()
|
||||
):
|
||||
freekassa_name = settings.get_freekassa_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
|
||||
@@ -75,6 +75,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
|
||||
|
||||
@staticmethod
|
||||
def _any_channel_has_disable_flag(channels: list[dict]) -> bool:
|
||||
"""Check if any channel in the list has disable-on-leave flags set."""
|
||||
return any(ch.get('disable_trial_on_leave', True) or ch.get('disable_paid_on_leave', False) for ch in channels)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
@@ -126,12 +131,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
if not unsubscribed:
|
||||
# All subscribed -- reactivate if needed
|
||||
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
if self._any_channel_has_disable_flag(all_channels):
|
||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||
return await handler(event, data)
|
||||
|
||||
# User is NOT subscribed to all channels
|
||||
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
if self._any_channel_has_disable_flag(unsubscribed):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, all_channels)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
@@ -152,7 +157,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
if not unsubscribed_fresh:
|
||||
# Now subscribed to all channels
|
||||
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
if self._any_channel_has_disable_flag(all_channels_fresh):
|
||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -345,9 +350,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
channels: list[dict],
|
||||
) -> None:
|
||||
"""Deactivate subscription when user unsubscribes from required channels."""
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -359,18 +361,13 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
return
|
||||
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
pass
|
||||
elif not subscription.is_trial:
|
||||
return
|
||||
|
||||
if is_active_paid_subscription(subscription):
|
||||
logger.info(
|
||||
'Skipping deactivation: user has active paid subscription',
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
# Per-channel settings: check if any unsubscribed channel requires deactivation
|
||||
unsubscribed = [ch for ch in channels if not ch.get('is_subscribed', False)]
|
||||
should_disable = any(
|
||||
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
|
||||
for ch in unsubscribed
|
||||
)
|
||||
if not should_disable:
|
||||
return
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
@@ -422,9 +419,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
||||
"""Reactivate subscription after user subscribes to all required channels."""
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
|
||||
@@ -240,7 +240,7 @@ class AdminNotificationService:
|
||||
|
||||
return mapping.get(promo_type, f'ℹ️ {promo_type}')
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> list[str]:
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign, *, tariff_name: str | None = None) -> list[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f'💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}',
|
||||
@@ -249,14 +249,24 @@ class AdminNotificationService:
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1)
|
||||
details = [
|
||||
f'📅 Дней подписки: {campaign.subscription_duration_days or 0}',
|
||||
f'📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ',
|
||||
f'📱 Устройства: {campaign.subscription_device_limit or default_devices}',
|
||||
f'📅 {campaign.subscription_duration_days or 0} дн. '
|
||||
f'• 📊 {campaign.subscription_traffic_gb or 0} ГБ '
|
||||
f'• 📱 {campaign.subscription_device_limit or default_devices} устр.',
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f'🌐 Сквады: {len(campaign.subscription_squads)} шт.')
|
||||
return details
|
||||
|
||||
if campaign.is_tariff_bonus:
|
||||
name = tariff_name or f'ID {campaign.tariff_id}'
|
||||
details = [f'📦 Тариф: <b>{name}</b>']
|
||||
if campaign.tariff_duration_days:
|
||||
details.append(f'📅 Период: {campaign.tariff_duration_days} дней')
|
||||
return details
|
||||
|
||||
if campaign.is_none_bonus:
|
||||
return ['🔗 Только отслеживание']
|
||||
|
||||
return ['ℹ️ Бонусы не предусмотрены']
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
@@ -1035,40 +1045,50 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = '🆕 Новый пользователь' if not user else '👥 Уже зарегистрирован'
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f'@{telegram_user.username}' if telegram_user.username else 'отсутствует'
|
||||
user_status = '🆕 Новый' if not user else '👥 Существующий'
|
||||
|
||||
message_lines = [
|
||||
'📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>',
|
||||
'📣 <b>ПЕРЕХОД ПО РК</b>',
|
||||
'',
|
||||
f'🧾 <b>Кампания:</b> {campaign.name}',
|
||||
f'🆔 ID кампании: {campaign.id}',
|
||||
f'🔗 Start-параметр: <code>{campaign.start_parameter}</code>',
|
||||
f'🧾 {campaign.name} (<code>{campaign.start_parameter}</code>)',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {full_name}',
|
||||
f'🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>',
|
||||
f'📱 <b>Username:</b> {username}',
|
||||
user_status,
|
||||
'',
|
||||
promo_block,
|
||||
'',
|
||||
'🎯 <b>Бонус кампании:</b>',
|
||||
f'👤 {full_name} (<code>{telegram_user.id}</code>)',
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
if telegram_user.username:
|
||||
message_lines.append(f'📱 @{telegram_user.username}')
|
||||
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
# Промогруппа — только если есть
|
||||
if user:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
# Загружаем название тарифа для tariff-бонуса
|
||||
tariff_name = None
|
||||
if campaign.is_tariff_bonus and campaign.tariff_id:
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, campaign.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Бонус кампании
|
||||
bonus_lines = self._format_campaign_bonus(campaign, tariff_name=tariff_name)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1778,10 +1798,16 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self, text: str, keyboard: types.InlineKeyboardMarkup | None = None
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
media_file_id: str | None = None,
|
||||
media_type: str | None = None,
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
Если передан media_file_id, отправляет медиа в тот же топик вместе с текстом.
|
||||
"""
|
||||
# Respect runtime toggle for admin ticket notifications
|
||||
try:
|
||||
@@ -1797,8 +1823,59 @@ class AdminNotificationService:
|
||||
runtime_enabled=runtime_enabled,
|
||||
)
|
||||
return False
|
||||
|
||||
# Если есть медиа, отправляем фото с текстом как caption (если влезает) или текст + фото
|
||||
if media_file_id and media_type == 'photo':
|
||||
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
|
||||
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
async def _send_ticket_photo_notification(
|
||||
self,
|
||||
text: str,
|
||||
photo_file_id: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправить фото с текстом в тикет-топик.
|
||||
Если текст <= 1024 символов — отправляем фото с caption.
|
||||
Иначе — сначала текст, потом фото в тот же топик.
|
||||
"""
|
||||
if not self.chat_id:
|
||||
return False
|
||||
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
|
||||
try:
|
||||
if len(text) <= 1024:
|
||||
# Фото с caption — всё в одном сообщении
|
||||
photo_kwargs: dict = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
'caption': text,
|
||||
'parse_mode': 'HTML',
|
||||
}
|
||||
if thread_id:
|
||||
photo_kwargs['message_thread_id'] = thread_id
|
||||
if keyboard:
|
||||
photo_kwargs['reply_markup'] = keyboard
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
else:
|
||||
# Текст отдельно, фото следом в тот же топик
|
||||
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
photo_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
}
|
||||
if thread_id:
|
||||
photo_kwargs['message_thread_id'] = thread_id
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
|
||||
# Fallback: отправляем хотя бы текст
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
|
||||
"""
|
||||
Отправляет уведомление о подозрительной активности трафика
|
||||
|
||||
@@ -58,6 +58,8 @@ class ChannelSubscriptionService:
|
||||
'channel_link': ch.channel_link,
|
||||
'title': ch.title,
|
||||
'sort_order': ch.sort_order,
|
||||
'disable_trial_on_leave': ch.disable_trial_on_leave,
|
||||
'disable_paid_on_leave': ch.disable_paid_on_leave,
|
||||
}
|
||||
for ch in channels
|
||||
]
|
||||
@@ -69,6 +71,20 @@ class ChannelSubscriptionService:
|
||||
channels = await self.get_required_channels()
|
||||
return {ch['channel_id'] for ch in channels}
|
||||
|
||||
async def get_channel_settings(self, channel_id: str) -> dict | None:
|
||||
"""Get per-channel settings for a specific channel (from cache)."""
|
||||
channels = await self.get_required_channels()
|
||||
for ch in channels:
|
||||
if ch['channel_id'] == channel_id:
|
||||
return ch
|
||||
return None
|
||||
|
||||
def should_disable_subscription(self, channel: dict, is_trial: bool) -> bool:
|
||||
"""Check if a channel's settings require subscription deactivation."""
|
||||
if is_trial:
|
||||
return channel.get('disable_trial_on_leave', True)
|
||||
return channel.get('disable_paid_on_leave', False)
|
||||
|
||||
async def check_user_subscriptions(self, telegram_id: int) -> dict[str, bool]:
|
||||
"""Check user subscriptions to all required channels.
|
||||
|
||||
@@ -179,6 +195,8 @@ class ChannelSubscriptionService:
|
||||
'channel_link': ch.get('channel_link'),
|
||||
'title': ch.get('title'),
|
||||
'is_subscribed': subs.get(ch['channel_id'], False),
|
||||
'disable_trial_on_leave': ch.get('disable_trial_on_leave', True),
|
||||
'disable_paid_on_leave': ch.get('disable_paid_on_leave', False),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -32,6 +32,8 @@ class FreekassaPaymentMixin:
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_system_id: int | None = None,
|
||||
payment_method: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Freekassa.
|
||||
@@ -83,18 +85,23 @@ class FreekassaPaymentMixin:
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method': payment_method or 'freekassa',
|
||||
}
|
||||
|
||||
try:
|
||||
# Определяем payment_system_id: явно переданный > из настроек
|
||||
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
|
||||
|
||||
# Выбираем способ создания платежа: API или форма
|
||||
if settings.FREEKASSA_USE_API:
|
||||
# Используем API для создания заказа (нужно для NSPK СБП)
|
||||
# Если указан payment_system_id — всегда используем API
|
||||
if settings.FREEKASSA_USE_API or ps_id:
|
||||
# Используем API для создания заказа
|
||||
payment_url = await freekassa_service.create_order_and_get_url(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
logger.info('Freekassa API: создан заказ order_id url', order_id=order_id, payment_url=payment_url)
|
||||
else:
|
||||
@@ -339,7 +346,18 @@ class FreekassaPaymentMixin:
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
# Resolve display name from payment metadata (sub-method aware)
|
||||
display_name = settings.get_freekassa_display_name_html()
|
||||
try:
|
||||
meta = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
||||
pm = meta.get('payment_method', 'freekassa')
|
||||
if pm == 'freekassa_sbp':
|
||||
display_name = settings.get_freekassa_sbp_display_name_html()
|
||||
elif pm == 'freekassa_card':
|
||||
display_name = settings.get_freekassa_card_display_name_html()
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
|
||||
@@ -98,6 +98,20 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
],
|
||||
},
|
||||
'freekassa_sbp': {
|
||||
'default_display_name': settings.get_freekassa_sbp_display_name(),
|
||||
'is_configured': settings.is_freekassa_sbp_enabled(),
|
||||
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'freekassa_card': {
|
||||
'default_display_name': settings.get_freekassa_card_display_name(),
|
||||
'is_configured': settings.is_freekassa_card_enabled(),
|
||||
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'cloudpayments': {
|
||||
'default_display_name': settings.get_cloudpayments_display_name(),
|
||||
'is_configured': settings.is_cloudpayments_enabled(),
|
||||
@@ -151,6 +165,8 @@ DEFAULT_METHOD_ORDER = [
|
||||
'platega',
|
||||
'wata',
|
||||
'freekassa',
|
||||
'freekassa_sbp',
|
||||
'freekassa_card',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Permission Engine — RBAC + ABAC evaluation for admin cabinet.
|
||||
|
||||
Combines role-based permission checks (fnmatch wildcards) with
|
||||
attribute-based access policies (time ranges, IP whitelists).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from datetime import UTC, datetime
|
||||
from fnmatch import fnmatch
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models import AccessPolicy, User
|
||||
|
||||
|
||||
SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _is_legacy_admin(user: User) -> bool:
|
||||
"""Check if user is a legacy config-based admin (ADMIN_IDS / ADMIN_EMAILS)."""
|
||||
return settings.is_admin(
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email if user.email_verified else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission Registry — section -> available actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PERMISSION_REGISTRY: dict[str, list[str]] = {
|
||||
'users': [
|
||||
'read',
|
||||
'edit',
|
||||
'block',
|
||||
'delete',
|
||||
'sync',
|
||||
'promo_group',
|
||||
'balance',
|
||||
'subscription',
|
||||
'send_offer',
|
||||
'referral',
|
||||
],
|
||||
'tickets': ['read', 'reply', 'close', 'settings'],
|
||||
'stats': ['read', 'export'],
|
||||
'broadcasts': ['read', 'create', 'edit', 'delete', 'send'],
|
||||
'tariffs': ['read', 'create', 'edit', 'delete'],
|
||||
'promocodes': ['read', 'create', 'edit', 'delete', 'stats'],
|
||||
'promo_groups': ['read', 'create', 'edit', 'delete'],
|
||||
'promo_offers': ['read', 'create', 'edit', 'send'],
|
||||
'campaigns': ['read', 'create', 'edit', 'delete', 'stats'],
|
||||
'partners': ['read', 'edit', 'approve', 'revoke', 'settings'],
|
||||
'withdrawals': ['read', 'approve', 'reject'],
|
||||
'payments': ['read', 'edit', 'export'],
|
||||
'payment_methods': ['read', 'edit'],
|
||||
'servers': ['read', 'edit'],
|
||||
'remnawave': ['read', 'sync', 'manage'],
|
||||
'traffic': ['read', 'export'],
|
||||
'settings': ['read', 'edit'],
|
||||
'roles': ['read', 'create', 'edit', 'delete', 'assign'],
|
||||
'audit_log': ['read', 'export'],
|
||||
'channels': ['read', 'edit'],
|
||||
'ban_system': ['read', 'edit', 'ban', 'unban'],
|
||||
'wheel': ['read', 'edit'],
|
||||
'apps': ['read', 'edit'],
|
||||
'email_templates': ['read', 'edit'],
|
||||
'pinned_messages': ['read', 'create', 'edit', 'delete'],
|
||||
'updates': ['read', 'manage'],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_all_permissions() -> list[str]:
|
||||
"""Return flat list of all permissions: ``['users:read', 'users:edit', ...]``."""
|
||||
return [f'{section}:{action}' for section, actions in PERMISSION_REGISTRY.items() for action in actions]
|
||||
|
||||
|
||||
def permission_matches(user_perm: str, required_perm: str) -> bool:
|
||||
"""Check if *user_perm* grants access for *required_perm*.
|
||||
|
||||
Wildcard rules (fnmatch):
|
||||
- ``*:*`` matches everything
|
||||
- ``users:*`` matches ``users:read``, ``users:edit``, ...
|
||||
- ``users:read`` matches only ``users:read``
|
||||
"""
|
||||
return fnmatch(required_perm, user_perm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal ABAC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _policy_matches_resource(policy: AccessPolicy, required_perm: str) -> bool:
|
||||
"""Check if an ABAC policy applies to the requested permission.
|
||||
|
||||
``policy.resource`` is the section pattern (e.g. ``users`` or ``*``).
|
||||
``policy.actions`` is a list of action patterns (e.g. ``['read', '*']``).
|
||||
"""
|
||||
if ':' not in required_perm:
|
||||
return False
|
||||
|
||||
section, action = required_perm.split(':', maxsplit=1)
|
||||
|
||||
if not fnmatch(section, policy.resource):
|
||||
return False
|
||||
|
||||
policy_actions: list[str] = policy.actions or []
|
||||
return any(fnmatch(action, pattern) for pattern in policy_actions)
|
||||
|
||||
|
||||
def _evaluate_conditions(
|
||||
conditions: dict | None,
|
||||
*,
|
||||
ip_address: str | None = None,
|
||||
) -> bool:
|
||||
"""Evaluate ABAC conditions dict. Returns ``True`` when ALL conditions are met.
|
||||
|
||||
Supported keys:
|
||||
- ``time_range``: ``{"start": "09:00", "end": "18:00"}`` -- current UTC time
|
||||
must fall within the range (inclusive start, exclusive end).
|
||||
- ``ip_whitelist``: ``["192.168.1.0/24", "10.0.0.1"]`` -- *ip_address* must
|
||||
match at least one entry (CIDR network or exact host).
|
||||
- ``max_actions_per_hour``: reserved for future rate-limit logic; always passes.
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
# --- time_range ---
|
||||
time_range = conditions.get('time_range')
|
||||
if time_range is not None:
|
||||
now = datetime.now(UTC).time()
|
||||
try:
|
||||
start = datetime.strptime(time_range['start'], '%H:%M').time()
|
||||
end = datetime.strptime(time_range['end'], '%H:%M').time()
|
||||
except (KeyError, ValueError) as exc:
|
||||
logger.warning('Invalid time_range condition', condition=time_range, error=str(exc))
|
||||
return False
|
||||
|
||||
if start <= end:
|
||||
# Normal range, e.g. 09:00..18:00
|
||||
if not (start <= now < end):
|
||||
return False
|
||||
# Overnight range, e.g. 22:00..06:00
|
||||
elif not (now >= start or now < end):
|
||||
return False
|
||||
|
||||
# --- ip_whitelist ---
|
||||
ip_whitelist: list[str] | None = conditions.get('ip_whitelist')
|
||||
if ip_whitelist is not None:
|
||||
if ip_address is None:
|
||||
# No IP provided but whitelist required -- deny
|
||||
return False
|
||||
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(ip_address)
|
||||
except ValueError:
|
||||
logger.warning('Invalid client IP address', ip_address=ip_address)
|
||||
return False
|
||||
|
||||
matched = False
|
||||
for entry in ip_whitelist:
|
||||
try:
|
||||
network = ipaddress.ip_network(entry, strict=False)
|
||||
if client_ip in network:
|
||||
matched = True
|
||||
break
|
||||
except ValueError:
|
||||
logger.warning('Invalid IP whitelist entry', entry=entry)
|
||||
continue
|
||||
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
# --- max_actions_per_hour (stub) ---
|
||||
# Will be implemented with rate-limit counters later.
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""Stateless permission engine combining RBAC + ABAC evaluation."""
|
||||
|
||||
@staticmethod
|
||||
async def check_permission(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
required_permission: str,
|
||||
*,
|
||||
ip_address: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Evaluate whether *user* may perform *required_permission*.
|
||||
|
||||
Returns ``(allowed, reason)`` tuple.
|
||||
|
||||
Algorithm:
|
||||
1. Aggregate user permissions via ``UserRoleCRUD.get_user_permissions``.
|
||||
2. Check if any RBAC permission matches the required one (fnmatch).
|
||||
3. If base RBAC permission is **not** granted -- deny immediately.
|
||||
4. Fetch ABAC policies applicable to the user's roles.
|
||||
5. Evaluate matching policies in priority order; **deny wins over allow**
|
||||
at the same priority level.
|
||||
"""
|
||||
# Step 0 -- legacy config-based admins get full access
|
||||
if _is_legacy_admin(user):
|
||||
return True, 'Granted by legacy admin config'
|
||||
|
||||
# Step 1 -- aggregate RBAC permissions
|
||||
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
|
||||
if not permissions:
|
||||
logger.debug(
|
||||
'Permission denied: no active roles',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
)
|
||||
return False, 'No active roles assigned'
|
||||
|
||||
# Step 2 -- RBAC wildcard matching
|
||||
rbac_granted = any(permission_matches(perm, required_permission) for perm in permissions)
|
||||
|
||||
if not rbac_granted:
|
||||
logger.debug(
|
||||
'Permission denied: RBAC mismatch',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
permissions=permissions,
|
||||
)
|
||||
return False, 'Permission not granted by any role'
|
||||
|
||||
# Step 3 -- load ABAC policies for the user's roles
|
||||
user_roles = await UserRoleCRUD.get_user_roles(db, user.id)
|
||||
role_ids = [ur.role_id for ur in user_roles]
|
||||
policies = await AccessPolicyCRUD.get_policies_for_user(db, role_ids)
|
||||
|
||||
if not policies:
|
||||
# No ABAC policies -- RBAC alone grants access
|
||||
return True, 'Granted by RBAC'
|
||||
|
||||
# Step 4 -- evaluate ABAC policies (highest priority first, already sorted)
|
||||
explicit_deny = False
|
||||
deny_reason = ''
|
||||
|
||||
for policy in policies:
|
||||
if not _policy_matches_resource(policy, required_permission):
|
||||
continue
|
||||
|
||||
conditions_met = _evaluate_conditions(
|
||||
policy.conditions,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
if not conditions_met:
|
||||
# Conditions not satisfied -- this policy does not apply
|
||||
continue
|
||||
|
||||
if policy.effect == 'deny':
|
||||
explicit_deny = True
|
||||
deny_reason = f'Denied by policy: {policy.name}'
|
||||
logger.debug(
|
||||
'Permission denied by ABAC policy',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
policy_id=policy.id,
|
||||
policy_name=policy.name,
|
||||
)
|
||||
# Deny is final -- stop evaluation
|
||||
break
|
||||
|
||||
# effect == 'allow' does not override a prior deny at higher priority,
|
||||
# but since policies are sorted desc and deny breaks immediately,
|
||||
# reaching here means no deny has fired yet -- just continue.
|
||||
|
||||
if explicit_deny:
|
||||
return False, deny_reason
|
||||
|
||||
return True, 'Granted by RBAC + ABAC'
|
||||
|
||||
@staticmethod
|
||||
async def get_user_permissions(db: AsyncSession, user_id: int, user: User | None = None) -> dict:
|
||||
"""Return aggregated permission info for a user.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
'permissions': ['users:read', ...],
|
||||
'roles': ['editor', 'moderator'],
|
||||
'role_level': 50,
|
||||
}
|
||||
"""
|
||||
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user_id)
|
||||
|
||||
# Legacy config-based admins get full superadmin permissions
|
||||
# Level is SUPERADMIN_LEVEL + 1 so they can manage all roles including level-999
|
||||
if user is not None and not permissions and _is_legacy_admin(user):
|
||||
permissions = ['*:*']
|
||||
role_names = ['superadmin']
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
return {
|
||||
'permissions': permissions,
|
||||
'roles': role_names,
|
||||
'role_level': max_level,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def log_action(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
status: str = 'success',
|
||||
request_method: str | None = None,
|
||||
request_path: str | None = None,
|
||||
) -> None:
|
||||
"""Persist an admin audit log entry via ``AuditLogCRUD``."""
|
||||
await AuditLogCRUD.create(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status=status,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
RBAC bootstrap service.
|
||||
|
||||
Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
|
||||
config on bot startup. Runs once during the startup sequence.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Final
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdminRole, User, UserRole
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
SUPERADMIN_ROLE_NAME: Final[str] = 'Superadmin'
|
||||
|
||||
# Preset roles seeded on first run
|
||||
_PRESET_ROLES: list[dict] = [
|
||||
{
|
||||
'name': 'Superadmin',
|
||||
'description': 'Full system access',
|
||||
'level': 999,
|
||||
'permissions': ['*:*'],
|
||||
'color': '#EF4444',
|
||||
'icon': 'shield',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Admin',
|
||||
'description': 'Administrative access',
|
||||
'level': 100,
|
||||
'permissions': [
|
||||
'users:*',
|
||||
'tickets:*',
|
||||
'stats:*',
|
||||
'broadcasts:*',
|
||||
'tariffs:*',
|
||||
'promocodes:*',
|
||||
'promo_groups:*',
|
||||
'promo_offers:*',
|
||||
'campaigns:*',
|
||||
'partners:*',
|
||||
'withdrawals:*',
|
||||
'payments:*',
|
||||
'payment_methods:*',
|
||||
'servers:*',
|
||||
'remnawave:*',
|
||||
'traffic:*',
|
||||
'settings:*',
|
||||
'roles:read',
|
||||
'roles:create',
|
||||
'roles:edit',
|
||||
'roles:assign',
|
||||
'audit_log:*',
|
||||
'channels:*',
|
||||
'ban_system:*',
|
||||
'wheel:*',
|
||||
'apps:*',
|
||||
'email_templates:*',
|
||||
'pinned_messages:*',
|
||||
'updates:*',
|
||||
],
|
||||
'color': '#F59E0B',
|
||||
'icon': 'crown',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Moderator',
|
||||
'description': 'User and ticket management',
|
||||
'level': 50,
|
||||
'permissions': ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
|
||||
'color': '#3B82F6',
|
||||
'icon': 'user-shield',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Marketer',
|
||||
'description': 'Marketing tools access',
|
||||
'level': 30,
|
||||
'permissions': [
|
||||
'campaigns:*',
|
||||
'broadcasts:*',
|
||||
'promocodes:*',
|
||||
'promo_offers:*',
|
||||
'promo_groups:*',
|
||||
'stats:read',
|
||||
'pinned_messages:*',
|
||||
'wheel:*',
|
||||
],
|
||||
'color': '#8B5CF6',
|
||||
'icon': 'megaphone',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Support',
|
||||
'description': 'Ticket support access',
|
||||
'level': 20,
|
||||
'permissions': ['tickets:read', 'tickets:reply', 'users:read'],
|
||||
'color': '#10B981',
|
||||
'icon': 'headset',
|
||||
'is_system': True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
|
||||
"""Seed preset roles if they don't exist. Returns the Superadmin role."""
|
||||
superadmin_role: AdminRole | None = None
|
||||
|
||||
for preset in _PRESET_ROLES:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if preset['name'] == SUPERADMIN_ROLE_NAME:
|
||||
superadmin_role = existing
|
||||
continue
|
||||
|
||||
role = AdminRole(
|
||||
name=preset['name'],
|
||||
description=preset['description'],
|
||||
level=preset['level'],
|
||||
permissions=preset['permissions'],
|
||||
color=preset['color'],
|
||||
icon=preset['icon'],
|
||||
is_system=preset['is_system'],
|
||||
is_active=True,
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
logger.info('Seeded preset role', role_name=preset['name'], role_id=role.id)
|
||||
|
||||
if preset['name'] == SUPERADMIN_ROLE_NAME:
|
||||
superadmin_role = role
|
||||
|
||||
return superadmin_role
|
||||
|
||||
|
||||
async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
"""Ensure every user from ADMIN_IDS / ADMIN_EMAILS has the Superadmin role.
|
||||
|
||||
Also seeds preset roles on first run.
|
||||
Idempotent: skips users who already hold an active Superadmin assignment.
|
||||
Commits only when at least one change was made.
|
||||
"""
|
||||
try:
|
||||
admin_ids = settings.get_admin_ids()
|
||||
admin_emails = settings.get_admin_emails()
|
||||
|
||||
# ── 1. Ensure preset roles exist (seeds on first run) ──────────
|
||||
superadmin_role = await _ensure_preset_roles(db)
|
||||
|
||||
if superadmin_role is None:
|
||||
logger.error('Failed to resolve Superadmin role after seeding')
|
||||
return
|
||||
|
||||
if not admin_ids and not admin_emails:
|
||||
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
role_id: int = superadmin_role.id
|
||||
assigned_count = 0
|
||||
|
||||
# ── 2. Process admin telegram IDs ──────────────────────────────
|
||||
for telegram_id in admin_ids:
|
||||
assigned = await _ensure_role_by_telegram_id(db, telegram_id=telegram_id, role_id=role_id)
|
||||
if assigned:
|
||||
assigned_count += 1
|
||||
|
||||
# ── 3. Process admin emails ────────────────────────────────────
|
||||
for email in admin_emails:
|
||||
assigned = await _ensure_role_by_email(db, email=email, role_id=role_id)
|
||||
if assigned:
|
||||
assigned_count += 1
|
||||
|
||||
# ── 4. Commit all changes ──────────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
if assigned_count > 0:
|
||||
logger.info(
|
||||
'Superadmin bootstrap completed',
|
||||
assigned_count=assigned_count,
|
||||
role_id=role_id,
|
||||
)
|
||||
else:
|
||||
logger.debug('Superadmin bootstrap: no new assignments needed')
|
||||
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception('Failed to bootstrap superadmins, continuing startup')
|
||||
|
||||
|
||||
async def _ensure_role_by_telegram_id(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
role_id: int,
|
||||
) -> bool:
|
||||
"""Assign Superadmin role to user found by telegram_id. Returns True if assigned."""
|
||||
result = await db.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.debug(
|
||||
'Admin user not yet registered, skipping',
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=str(telegram_id))
|
||||
|
||||
|
||||
async def _ensure_role_by_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
role_id: int,
|
||||
) -> bool:
|
||||
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.debug(
|
||||
'Admin user (email) not yet registered, skipping',
|
||||
email=email,
|
||||
)
|
||||
return False
|
||||
|
||||
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=email)
|
||||
|
||||
|
||||
async def _assign_if_missing(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
role_id: int,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""Create or reactivate a UserRole row for this user/role pair.
|
||||
|
||||
Handles the unique constraint on (user_id, role_id) by checking for
|
||||
ANY existing assignment (active or inactive) and reactivating if needed.
|
||||
|
||||
Returns True if a new assignment was created or an inactive one was reactivated.
|
||||
"""
|
||||
# Check for ANY existing assignment (active or not) to respect unique constraint
|
||||
result = await db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.role_id == role_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
if existing.is_active:
|
||||
logger.debug(
|
||||
'User already has Superadmin role',
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
)
|
||||
return False
|
||||
# Reactivate previously revoked assignment
|
||||
existing.is_active = True
|
||||
existing.assigned_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Reactivated Superadmin role for user',
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return True
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user_role)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
'Assigned Superadmin role to user',
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
identifier=identifier,
|
||||
user_role_id=user_role.id,
|
||||
)
|
||||
return True
|
||||
@@ -1471,14 +1471,12 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
# Используем один API клиент для всех операций сброса HWID
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
try:
|
||||
hwid_api_cm = self.get_api_client()
|
||||
hwid_api_client = await hwid_api_cm.__aenter__()
|
||||
await hwid_api_cm.__aenter__()
|
||||
except Exception as api_init_error:
|
||||
logger.warning('⚠️ Не удалось создать API клиент для сброса HWID', api_init_error=api_init_error)
|
||||
hwid_api_client = None
|
||||
hwid_api_cm = None
|
||||
|
||||
try:
|
||||
@@ -1499,19 +1497,8 @@ class RemnaWaveService:
|
||||
|
||||
logger.info('🗑️ Деактивация подписки пользователя (нет в панели)', telegram_id=telegram_id)
|
||||
|
||||
if db_user.remnawave_uuid and hwid_api_client:
|
||||
try:
|
||||
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
|
||||
if devices_reset:
|
||||
logger.info(
|
||||
'🔧 Сброшены HWID устройства для пользователя', telegram_id=telegram_id
|
||||
)
|
||||
except Exception as hwid_error:
|
||||
logger.error(
|
||||
'❌ Ошибка сброса HWID устройств для',
|
||||
telegram_id=telegram_id,
|
||||
hwid_error=hwid_error,
|
||||
)
|
||||
# NOTE: Не сбрасываем HWID здесь — пользователь уже удалён из панели,
|
||||
# API вернёт 404, UUID очищается ниже (cleanup_mutation)
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
@@ -615,6 +615,16 @@ class RemnaWaveWebhookService:
|
||||
subscription.subscription_url = subscription_url
|
||||
changed = True
|
||||
|
||||
# Sync subscription crypto link (for HAPP_CRYPT4_LINK)
|
||||
subscription_crypto_link = data.get('subscriptionCryptoLink')
|
||||
if (
|
||||
subscription_crypto_link
|
||||
and self._is_valid_link(subscription_crypto_link)
|
||||
and subscription.subscription_crypto_link != subscription_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
Отправляет красивое сообщение с информацией о системе при запуске бота.
|
||||
"""
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Final
|
||||
|
||||
@@ -413,12 +414,12 @@ async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str
|
||||
filename=file_name,
|
||||
)
|
||||
|
||||
# Текст сообщения
|
||||
# Текст сообщения (escape HTML в error_type/message — они могут содержать <class ...>)
|
||||
message_text = (
|
||||
f'<b>Remnawave Bedolaga Bot</b>\n\n'
|
||||
f'❌ Бот упал с ошибкой\n\n'
|
||||
f'<b>Тип:</b> <code>{error_type}</code>\n'
|
||||
f'<b>Сообщение:</b> <code>{error_message[:CRASH_ERROR_PREVIEW_LENGTH]}</code>\n'
|
||||
f'<b>Тип:</b> <code>{html.escape(error_type)}</code>\n'
|
||||
f'<b>Сообщение:</b> <code>{html.escape(error_message[:CRASH_ERROR_PREVIEW_LENGTH])}</code>\n'
|
||||
)
|
||||
|
||||
# Добавляем рекомендации если есть
|
||||
|
||||
@@ -1203,6 +1203,22 @@ class UserService:
|
||||
logger.error('❌ Ошибка удаления подписки', error=e)
|
||||
|
||||
try:
|
||||
from app.database.models import (
|
||||
AccessPolicy,
|
||||
AdminAuditLog,
|
||||
AdminRole,
|
||||
UserRole,
|
||||
WithdrawalRequest,
|
||||
)
|
||||
|
||||
await db.execute(delete(AdminAuditLog).where(AdminAuditLog.user_id == user_id))
|
||||
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user_id))
|
||||
await db.execute(
|
||||
update(WithdrawalRequest).where(WithdrawalRequest.processed_by == user_id).values(processed_by=None)
|
||||
)
|
||||
await db.execute(update(AdminRole).where(AdminRole.created_by == user_id).values(created_by=None))
|
||||
await db.execute(update(UserRole).where(UserRole.assigned_by == user_id).values(assigned_by=None))
|
||||
await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == user_id).values(created_by=None))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
logger.info('✅ Пользователь окончательно удален из базы', user_id=user_id)
|
||||
|
||||
+26
-22
@@ -63,35 +63,39 @@ def calculate_user_price(user: User | None, base_price: int, period_days: int, c
|
||||
if not base_price or base_price <= 0:
|
||||
return PriceInfo(base_price=base_price or 0, final_price=base_price or 0, discount_percent=0)
|
||||
|
||||
# Get discount percentage
|
||||
# Step 1: Get promo group discount
|
||||
if user:
|
||||
# Get user's promo group discount for this category
|
||||
discount_percent = user.get_promo_discount(category, period_days)
|
||||
group_discount = user.get_promo_discount(category, period_days)
|
||||
else:
|
||||
# For None user, use base settings discount
|
||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||
group_discount = settings.get_base_promo_group_period_discount(period_days)
|
||||
|
||||
# Step 2: Get promo offer discount (stacking)
|
||||
promo_offer_discount = 0
|
||||
if user:
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
promo_offer_discount = get_user_active_promo_discount_percent(user)
|
||||
|
||||
# Apply both discounts sequentially (same as cabinet)
|
||||
final_price = base_price
|
||||
if group_discount > 0:
|
||||
final_price = final_price - (final_price * group_discount) // 100
|
||||
if promo_offer_discount > 0:
|
||||
final_price = final_price - (final_price * promo_offer_discount) // 100
|
||||
|
||||
# Effective combined discount percent
|
||||
if final_price < base_price:
|
||||
discount_percent = round((base_price - final_price) * 100 / base_price)
|
||||
else:
|
||||
discount_percent = 0
|
||||
|
||||
logger.debug(
|
||||
'calculate_user_price: user=, base_price=, period_days=, category=, discount_percent',
|
||||
telegram_id=user.telegram_id if user else 'None',
|
||||
base_price=base_price,
|
||||
period_days=period_days,
|
||||
category=category,
|
||||
discount_percent=discount_percent,
|
||||
)
|
||||
|
||||
if discount_percent <= 0:
|
||||
return PriceInfo(base_price=base_price, final_price=base_price, discount_percent=0)
|
||||
|
||||
# Calculate discounted price
|
||||
discount_value = (base_price * discount_percent) // 100
|
||||
final_price = base_price - discount_value
|
||||
|
||||
logger.debug(
|
||||
'Calculated price for user -> (-%) [category=, period=]',
|
||||
'calculate_user_price',
|
||||
telegram_id=user.telegram_id if user else 'None',
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
group_discount=group_discount,
|
||||
promo_offer_discount=promo_offer_discount,
|
||||
discount_percent=discount_percent,
|
||||
category=category,
|
||||
period_days=period_days,
|
||||
|
||||
@@ -181,7 +181,7 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
if forced_limit is not None and forced_limit > 0:
|
||||
_logger.info(
|
||||
'DEVICES_SELECTION disabled, using forced limit',
|
||||
forced_limit=forced_limit,
|
||||
@@ -189,7 +189,7 @@ def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None:
|
||||
subscription_id=getattr(subscription, 'id', None),
|
||||
)
|
||||
return forced_limit
|
||||
# Если forced_limit не задан, используем device_limit из подписки
|
||||
# forced_limit не задан или равен 0 — используем device_limit из подписки,
|
||||
# чтобы при смене тарифа лимит устройств обновлялся в панели
|
||||
|
||||
limit = getattr(subscription, 'device_limit', None)
|
||||
|
||||
@@ -217,6 +217,21 @@ async def main():
|
||||
if not token_ok:
|
||||
stage.warning('Не удалось создать/проверить дефолтный веб-API токен')
|
||||
|
||||
async with timeline.stage(
|
||||
'RBAC bootstrap',
|
||||
'🔐',
|
||||
success_message='RBAC roles and superadmins ready',
|
||||
) as stage:
|
||||
try:
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.rbac_bootstrap_service import bootstrap_superadmins
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await bootstrap_superadmins(db)
|
||||
except Exception as error:
|
||||
stage.warning(f'RBAC bootstrap warning: {error}')
|
||||
logger.error('RBAC bootstrap failed', error=error)
|
||||
|
||||
async with timeline.stage(
|
||||
'Синхронизация тарифов из конфига',
|
||||
'💰',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add per-channel disable settings (disable_trial_on_leave, disable_paid_on_leave)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-02-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0010'
|
||||
down_revision: Union[str, None] = '0009'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.columns '
|
||||
"WHERE table_schema = 'public' AND table_name = :table AND column_name = :col)"
|
||||
),
|
||||
{'table': table, 'col': column},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column('required_channels', 'disable_trial_on_leave'):
|
||||
op.add_column(
|
||||
'required_channels',
|
||||
sa.Column('disable_trial_on_leave', sa.Boolean(), nullable=False, server_default='true'),
|
||||
)
|
||||
if not _has_column('required_channels', 'disable_paid_on_leave'):
|
||||
op.add_column(
|
||||
'required_channels',
|
||||
sa.Column('disable_paid_on_leave', sa.Boolean(), nullable=False, server_default='false'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('required_channels', 'disable_paid_on_leave')
|
||||
op.drop_column('required_channels', 'disable_trial_on_leave')
|
||||
@@ -0,0 +1,109 @@
|
||||
"""add RBAC tables (admin_roles, user_roles, access_policies, admin_audit_log)
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-02-25
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = '0011'
|
||||
down_revision: Union[str, None] = '0010'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
'SELECT EXISTS (SELECT 1 FROM information_schema.tables '
|
||||
"WHERE table_schema = 'public' AND table_name = :name)"
|
||||
),
|
||||
{'name': table_name},
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table('admin_roles'):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
'admin_roles',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(100), unique=True, nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('level', sa.Integer, server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('permissions', JSONB, server_default='[]', nullable=False),
|
||||
sa.Column('color', sa.String(7), nullable=True),
|
||||
sa.Column('icon', sa.String(50), nullable=True),
|
||||
sa.Column('is_system', sa.Boolean, server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
|
||||
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'user_roles',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('role_id', sa.Integer, sa.ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('assigned_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
|
||||
sa.UniqueConstraint('user_id', 'role_id', name='uq_user_role'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'access_policies',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(200), nullable=False),
|
||||
sa.Column('description', sa.Text, nullable=True),
|
||||
sa.Column('role_id', sa.Integer, sa.ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=True),
|
||||
sa.Column('priority', sa.Integer, server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('effect', sa.String(10), nullable=False),
|
||||
sa.Column('conditions', JSONB, server_default='{}', nullable=False),
|
||||
sa.Column('resource', sa.String(100), nullable=False),
|
||||
sa.Column('actions', JSONB, server_default='[]', nullable=False),
|
||||
sa.Column('is_active', sa.Boolean, server_default=sa.text('true'), nullable=False),
|
||||
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'admin_audit_log',
|
||||
sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('action', sa.String(100), nullable=False),
|
||||
sa.Column('resource_type', sa.String(50), nullable=True),
|
||||
sa.Column('resource_id', sa.String(100), nullable=True),
|
||||
sa.Column('details', JSONB, nullable=True),
|
||||
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text, nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('request_method', sa.String(10), nullable=True),
|
||||
sa.Column('request_path', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_index('ix_admin_audit_user_created', 'admin_audit_log', ['user_id', 'created_at'])
|
||||
op.create_index('ix_admin_audit_resource', 'admin_audit_log', ['resource_type', 'resource_id'])
|
||||
op.create_index('ix_admin_audit_created', 'admin_audit_log', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_admin_audit_created', table_name='admin_audit_log')
|
||||
op.drop_index('ix_admin_audit_resource', table_name='admin_audit_log')
|
||||
op.drop_index('ix_admin_audit_user_created', table_name='admin_audit_log')
|
||||
op.drop_table('admin_audit_log')
|
||||
op.drop_table('access_policies')
|
||||
op.drop_table('user_roles')
|
||||
op.drop_table('admin_roles')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.18.0"
|
||||
version = "3.20.1"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user