Compare commits

...

37 Commits

Author SHA1 Message Date
Egor 2044cecc6e Merge pull request #2650 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.20.1
2026-02-25 15:29:42 +03:00
github-actions[bot] ffffccb389 chore(main): release 3.20.1 2026-02-25 12:29:17 +00:00
Egor 28d263fc8d Merge pull request #2649 from BEDOLAGA-DEV/dev
Dev
2026-02-25 15:28:51 +03:00
Fringg bfef7cc629 fix: prevent race condition expiring active daily subscriptions
MonitoringService._check_expired_subscriptions() was marking daily
subscriptions as expired before DailySubscriptionService could charge
and extend them. Now get_expired_subscriptions() excludes active
(non-paused) daily subs — they are managed by DailySubscriptionService.

Also fix cabinet "0m until next charge" display: return None when
next_daily_charge_at is in the past instead of a stale datetime.
2026-02-25 15:07:24 +03:00
Fringg a696896d2c fix: make migrations 0010/0011 idempotent, escape HTML in crash notification
- 0010: add _has_column() guard before adding disable_trial/paid_on_leave
  (columns already exist from 0001 create_all on fresh DB)
- 0011: add _has_table() guard — skip if admin_roles already exists
- startup_notification_service: html.escape() error_type and error_message
  to prevent TelegramBadRequest when error contains <class ...>
2026-02-25 13:48:39 +03:00
Egor fd2e419e8e Merge pull request #2648 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.20.0
2026-02-25 12:49:20 +03:00
github-actions[bot] aaf0263fda chore(main): release 3.20.0 2026-02-25 09:48:22 +00:00
Egor d4d5031cc2 Merge pull request #2647 from BEDOLAGA-DEV/dev
Dev
2026-02-25 12:47:57 +03:00
Fringg b2d7abf5bd fix: resolve ruff lint errors (import sorting, unused variable) 2026-02-25 12:42:26 +03:00
Fringg 0f9f843236 style: format branding routes 2026-02-25 12:40:49 +03:00
Fringg cab425cfac style: format freekassa handler and keyboard files 2026-02-25 12:39:21 +03:00
Fringg 0da0c5547d feat: add separate Freekassa SBP and card payment methods
Split Freekassa into sub-methods: СБП/QR (i=44) and Карты РФ (i=36).
Each method has independent enable/display_name settings, dedicated
handlers, keyboard buttons, and correct payment_system_id routing.
Webhook notifications resolve display name from payment metadata.
2026-02-25 12:32:05 +03:00
Fringg 988d0e5c2f fix: initialize logger in bot_configuration.py
Add missing structlog import and logger initialization.
Without this, any code path hitting logger.info/warning/error
would raise NameError at runtime.
2026-02-25 11:55:07 +03:00
Fringg 1ce91749aa fix: resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave
1. Remove pointless HWID reset during auto-sync deactivation — user
   doesn't exist in panel, API returns 404, UUID is cleaned up below.

2. Clean up RESTRICT FK references (AdminAuditLog, WithdrawalRequest,
   AdminRole, UserRole, AccessPolicy) before deleting user to prevent
   IntegrityError on admin_audit_log_user_id_fkey.

3. Fix device limit not being sent to RemnaWave when
   DEVICES_SELECTION_DISABLED_AMOUNT=0: treat 0 as "no forced override"
   instead of sending hwidDeviceLimit:0 (which Remnawave interprets as
   unlimited). Now falls through to subscription.device_limit from tariff.

4. Add info-level logging to POST /api/users (was debug) to match
   existing PATCH logging for device limit diagnostics.
2026-02-25 11:53:49 +03:00
Fringg 731eb24364 fix: remove gemini-effect and noise from allowed background types 2026-02-25 07:43:46 +03:00
Fringg a15403b8b6 feat: add validation to animation config API
- Add Literal type whitelist for background type field
- Add settings dict validation (max 20 keys, no nested objects, bounded values)
- Add opacity (0-1) and blur (0-100) bounds with Pydantic Field constraints
- Fix mutable default dict with Field(default_factory=dict)
2026-02-25 07:13:07 +03:00
Egor ff8f3d02cf Merge pull request #2646 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.19.0
2026-02-25 06:37:01 +03:00
github-actions[bot] 69f57eddd6 chore(main): release 3.19.0 2026-02-25 03:36:23 +00:00
Egor fe567fffa8 Merge pull request #2645 from BEDOLAGA-DEV/dev
Dev
2026-02-25 06:35:59 +03:00
Fringg f300e07ce2 chore: ruff format 2026-02-25 06:34:22 +03:00
Fringg 628997fb48 fix: stack promo group + promo offer discounts in bot (matching cabinet) 2026-02-25 05:49:09 +03:00
Fringg 3dc0b93bdf fix: always include details in successful audit log entries 2026-02-25 05:31:32 +03:00
Fringg bea9da96d4 feat: capture query params in audit log details for all requests 2026-02-25 05:24:16 +03:00
Fringg 388fc7ee67 feat: add resource_type and request body to audit log entries 2026-02-25 05:11:43 +03:00
Fringg f6b6e22a95 feat: allow editing system roles 2026-02-25 04:45:41 +03:00
Fringg 60c4fe2e23 feat: add granular user permissions (balance, subscription, promo_group, referral, send_offer)
Split users:edit into fine-grained permissions for balance management,
subscription actions, promo group editing, referral commission, and
sending promo offers.
2026-02-25 04:42:32 +03:00
Fringg c1da8a4dba fix: RBAC audit log action filter and legacy admin level
- Change audit log action filter from exact match to ILIKE substring
  search so admins can search by partial action names
- Return level 1000 (not 999) for legacy config-based admins in
  /me/permissions so frontend correctly enables role management buttons
2026-02-25 04:07:09 +03:00
Fringg af6686ccfa fix: extract real client IP from X-Forwarded-For/X-Real-IP headers
Behind Docker reverse proxy, request.client.host always returns
the proxy container IP (172.20.0.2). Now reads X-Forwarded-For
first, then X-Real-IP, falling back to request.client.host.
2026-02-25 03:49:33 +03:00
Fringg 8893fc128e fix: grant legacy config-based admins full RBAC access
Legacy admins (ADMIN_IDS/ADMIN_EMAILS) had no RBAC roles in DB,
so check_permission returned 'No active roles assigned' and
role_level was 0, disabling all role management UI.

- check_permission: bypass RBAC for legacy admins
- get_user_permissions: return *:* and level 999 for legacy admins
- _get_admin_level: legacy admins get level 1000 (above superadmin)
2026-02-25 03:47:37 +03:00
Fringg 4598c2785a fix: RBAC API response format fixes and audit log user info
- Simplify permission registry to return flat list[PermissionSection] with actions as list[str]
- Add user_first_name and user_email to audit log entries via selectinload
- Fix unused import and naming convention lint warnings
2026-02-25 03:40:40 +03:00
Fringg 5a7dd3f164 fix: align RBAC route prefixes with frontend API paths
Frontend expects /admin/rbac/* namespace but backend used /admin/roles,
/admin/policies, /admin/audit-log. Updated:
- admin_roles.py: prefix /admin/roles → /admin/rbac, endpoints get /roles prefix
- admin_policies.py: prefix /admin/policies → /admin/rbac/policies
- admin_audit_log.py: prefix /admin/audit-log → /admin/rbac/audit-log
- assignments endpoints: /assign → /assignments
- role users endpoint: GET /roles/{role_id}/users with per-role filtering
2026-02-25 03:28:14 +03:00
Fringg bc7d0612f1 fix: specify foreign_keys on User.admin_roles_rel to resolve ambiguous join
UserRole has two FKs to users (user_id and assigned_by), causing
SQLAlchemy AmbiguousForeignKeysError on mapper initialization.
2026-02-25 03:23:41 +03:00
Fringg 1646f04bde fix: address RBAC review findings (CRITICAL + HIGH)
- stats:read → remnawave:manage for node restart/toggle (CRITICAL)
- add is_system guard on role update endpoint
- add Query bounds on /users limit/offset (ge/le)
- add db.rollback() in bootstrap exception handler
- migration: default=0 → server_default for level/priority columns
- CSV export: add formula injection sanitization
2026-02-25 03:17:06 +03:00
Fringg 3fee54f657 feat: add RBAC + ABAC permission system for admin cabinet
Backend:
- 4 new models: AdminRole, UserRole, AccessPolicy, AdminAuditLog
- Permission engine with RBAC wildcard matching + ABAC policy evaluation
- 26 permission sections (78 unique permissions) covering all admin routes
- require_permission() FastAPI dependency for route-level access control
- JWT tokens carry permissions, roles, role_level for frontend checks
- Admin roles CRUD with level-based hierarchy (viewers → superadmin)
- ABAC policies with time ranges and IP whitelist conditions
- Full audit log with CSV export
- Bootstrap service seeds 5 preset roles and assigns superadmins at startup
- Alembic migration 0011 for all RBAC tables
2026-02-25 03:02:40 +03:00
Fringg a594a0f79f fix: improve campaign notifications and ticket media in admin topics
- Campaign notifications: add tariff bonus display, hide empty promo group,
  compact format matching purchase notification style
- Ticket notifications: send media (photos) in the same topic as the text
  notification instead of separately. Uses caption for short texts, sequential
  messages for long texts with correct message_thread_id routing
2026-02-25 00:44:44 +03:00
Fringg 3642462670 feat: add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug
- Fix critical bug: is_active_paid_subscription() guard was blocking
  CHANNEL_REQUIRED_FOR_ALL from disabling paid subscriptions
- Add disable_trial_on_leave and disable_paid_on_leave columns to
  RequiredChannel model with Alembic migration 0010
- Refactor enforcement logic in channel_member.py and channel_checker.py
  to use per-channel settings instead of global env vars
- Update CRUD, Pydantic schemas, and admin API routes for new fields
- Add should_disable_subscription() and get_channel_settings() to
  channel_subscription_service for per-channel decision logic
2026-02-25 00:24:31 +03:00
Fringg 26efb157e4 fix: restore subscription_url and crypto_link after panel sync
_sync_subscription_to_panel() discarded the update_user() return value,
leaving subscription_url and subscription_crypto_link as None when
updating existing panel users. This caused "Connect devices" button
and HAPP_CRYPT4_LINK to disappear after admin subscription reset.

Also adds subscription_crypto_link sync to webhook user_modified handler
(was already present in user_revoked but missing from user_modified).
2026-02-24 23:50:21 +03:00
72 changed files with 3650 additions and 453 deletions
+7
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.18.0"
".": "3.20.1"
}
+51
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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)
+5 -5
View File
@@ -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:
+208
View File
@@ -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}"'},
)
+29 -29
View File
@@ -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()
+12 -12
View File
@@ -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)."""
+4 -4
View File
@@ -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."""
+13 -13
View File
@@ -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
View File
@@ -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:
+7 -7
View File
@@ -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."""
+13 -13
View File
@@ -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."""
+6 -6
View File
@@ -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."""
+5 -5
View File
@@ -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."""
+12 -12
View File
@@ -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."""
+227
View File
@@ -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}
+7 -7
View File
@@ -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),
+12 -12
View File
@@ -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."""
+30 -30
View File
@@ -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."""
+503
View File
@@ -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}
+8 -8
View File
@@ -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."""
+6 -6
View File
@@ -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."""
+9 -9
View File
@@ -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."""
+11 -11
View File
@@ -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."""
+9 -9
View File
@@ -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."""
+4 -4
View File
@@ -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."""
+2 -2
View File
@@ -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
+34 -30
View File
@@ -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),
):
"""
+10 -10
View File
@@ -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),
):
"""Получить все спины с фильтрами."""
+6 -6
View File
@@ -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)."""
+45 -9
View File
@@ -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
View File
@@ -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."""
+1 -1
View File
@@ -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)
+4 -1
View File
@@ -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()
+6 -6
View File
@@ -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."""
+7 -1
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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
+501
View File
@@ -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
+15 -1
View File
@@ -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()
+9 -1
View File
@@ -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),
),
)
)
)
+13
View File
@@ -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:
"""Получить последнее сообщение в тикете"""
+111
View File
@@ -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}>'
+11 -1
View File
@@ -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:
+3
View File
@@ -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
+139 -11
View File
@@ -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')
+16 -3
View File
@@ -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
+6 -14
View File
@@ -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)
+17 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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(
[
+15 -21
View File
@@ -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)
+104 -27
View File
@@ -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
+22 -4
View File
@@ -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',
]
+354
View File
@@ -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,
)
+298
View File
@@ -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
+3 -16
View File
@@ -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
+10
View File
@@ -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 -3
View File
@@ -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'
)
# Добавляем рекомендации если есть
+16
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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' }