From e0bedc8e780a2f91509517110639773e90bb6125 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 08:41:36 +0300 Subject: [PATCH 01/11] fix: superadmin role managed exclusively via env config Superadmin (level 999) assignments are now the sole domain of ADMIN_IDS/ADMIN_EMAILS environment variables. On startup, bootstrap reactivates env-listed users and revokes superadmin from users removed from env. API assign/revoke endpoints return 403 for superadmin-level roles. _ensure_role_by_email now requires email_verified (symmetric with revocation check). --- app/cabinet/routes/admin_roles.py | 62 ++++--------- app/services/rbac_bootstrap_service.py | 116 ++++++++++++++++++++----- 2 files changed, 112 insertions(+), 66 deletions(-) diff --git a/app/cabinet/routes/admin_roles.py b/app/cabinet/routes/admin_roles.py index c7191c29..9f26e380 100644 --- a/app/cabinet/routes/admin_roles.py +++ b/app/cabinet/routes/admin_roles.py @@ -441,6 +441,14 @@ async def assign_role( detail='Role not found', ) + # Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config + if role.level >= SUPERADMIN_LEVEL: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. ' + 'Add the user there and restart the bot.', + ) + admin_level = await _get_admin_level(db, admin) # Cannot assign a role with level >= own level @@ -450,13 +458,6 @@ async def assign_role( detail='Cannot assign a role with level >= your own role level', ) - # Superadmin assignments must be permanent — expiry would cause silent lockout - if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Superadmin role assignments cannot be time-limited', - ) - # Verify target user exists from app.database.crud.user import get_user_by_id @@ -505,9 +506,7 @@ async def revoke_role( admin: User = Depends(require_permission('roles:assign')), db: AsyncSession = Depends(get_cabinet_db), ): - """Revoke a role assignment. Cannot remove the last superadmin.""" - from app.config import settings - from app.database.crud.user import get_user_by_id + """Revoke a role assignment. Superadmin roles are managed via env config.""" from app.database.models import UserRole # Lock the assignment row (FOR UPDATE held until commit) @@ -526,6 +525,14 @@ async def revoke_role( detail='Associated role not found', ) + # Superadmin role is managed exclusively via env config + if role.level >= SUPERADMIN_LEVEL: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. ' + 'Remove the user from env and restart the bot.', + ) + admin_level = await _get_admin_level(db, admin) # Cannot revoke a role at or above own level @@ -535,33 +542,6 @@ async def revoke_role( detail='Cannot revoke a role at or above your own level', ) - # Block self-revocation of superadmin role - if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail='Cannot revoke your own superadmin role', - ) - - # Protect last superadmin (level 999). - # Advisory lock serializes concurrent superadmin revocations so two requests - # cannot both read count=2 and then both proceed to revoke. - if role.level == SUPERADMIN_LEVEL: - if not settings.is_sqlite(): - await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)')) - 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', - ) - - # Warn if target user is a legacy admin — RBAC revocation won't actually block access - target_user = await get_user_by_id(db, user_role.user_id) - is_target_legacy = target_user and settings.is_admin( - telegram_id=target_user.telegram_id, - email=target_user.email if target_user.email_verified else None, - ) - # Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE) user_role.is_active = False await db.flush() @@ -575,10 +555,4 @@ async def revoke_role( role_name=role.name, ) - result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id} - if is_target_legacy: - result_msg['warning'] = ( - 'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. ' - 'They retain full access until removed from those settings and the bot is restarted.' - ) - return result_msg + return {'message': 'Role revoked', 'assignment_id': assignment_id} diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py index 82fda20f..4f6763ad 100644 --- a/app/services/rbac_bootstrap_service.py +++ b/app/services/rbac_bootstrap_service.py @@ -10,6 +10,7 @@ from typing import Final import structlog from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.config import settings from app.database.crud.rbac import SUPERADMIN_LEVEL, UserRoleCRUD @@ -215,19 +216,25 @@ async def bootstrap_superadmins(db: AsyncSession) -> None: if assigned: assigned_count += 1 - # ── 4. Commit all changes ────────────────────────────────────── + # ── 4. Revoke superadmin from users NOT in env ─────────────── + revoked_count = await _revoke_stale_superadmins( + db, role_id=role_id, admin_ids=admin_ids, admin_emails=admin_emails, + ) + + # ── 5. Commit all changes ────────────────────────────────────── await db.commit() - if assigned_count > 0: + if assigned_count > 0 or revoked_count > 0: logger.info( 'Superadmin bootstrap completed', assigned_count=assigned_count, + revoked_count=revoked_count, role_id=role_id, ) else: - logger.debug('Superadmin bootstrap: no new assignments needed') + logger.debug('Superadmin bootstrap: no changes needed') - # ── 5. Safety: warn if no active superadmins exist ──────────── + # ── 6. Safety: warn if no active superadmins exist ──────────── await _warn_if_no_superadmins(db, admin_ids, admin_emails) except Exception: @@ -235,6 +242,64 @@ async def bootstrap_superadmins(db: AsyncSession) -> None: logger.exception('Failed to bootstrap superadmins, continuing startup') +async def _revoke_stale_superadmins( + db: AsyncSession, + *, + role_id: int, + admin_ids: list[int], + admin_emails: list[str], +) -> int: + """Revoke superadmin from users who are no longer in env config. + + Env config (ADMIN_IDS / ADMIN_EMAILS) is the single source of truth. + If a user was removed from env, their superadmin DB role is deactivated + on the next bot restart. + + Returns the number of revoked assignments. + """ + result = await db.execute( + select(UserRole) + .options(selectinload(UserRole.user)) + .where( + UserRole.role_id == role_id, + UserRole.is_active.is_(True), + ) + ) + active_assignments = result.scalars().all() + + admin_ids_set = set(admin_ids) + admin_emails_set = {e.lower() for e in admin_emails} + + revoked = 0 + for assignment in active_assignments: + user = assignment.user + if user is None: + continue + + # Check if user is still in env config. + # email_verified is required — symmetric with _ensure_role_by_email. + in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set + in_env_by_email = ( + user.email is not None + and user.email_verified + and user.email.lower() in admin_emails_set + ) + + if not in_env_by_id and not in_env_by_email: + assignment.is_active = False + await db.flush() + revoked += 1 + logger.warning( + 'Revoked Superadmin role: user removed from env config', + user_id=user.id, + telegram_id=user.telegram_id, + email=user.email, + user_role_id=assignment.id, + ) + + return revoked + + async def _warn_if_no_superadmins( db: AsyncSession, admin_ids: list[int], @@ -281,13 +346,18 @@ async def _ensure_role_by_email( 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())) + """Assign Superadmin role to user found by verified email (case-insensitive). Returns True if assigned.""" + result = await db.execute( + select(User).where( + func.lower(User.email) == email.lower(), + User.email_verified.is_(True), + ) + ) user = result.scalar_one_or_none() if user is None: logger.debug( - 'Admin user (email) not yet registered, skipping', + 'Admin user (email) not yet registered or not verified, skipping', email=email, ) return False @@ -302,13 +372,13 @@ async def _assign_if_missing( role_id: int, identifier: str, ) -> bool: - """Create a UserRole row if none exists for this user/role pair. + """Create or reactivate a UserRole row for this user/role pair. - If an assignment already exists (active or revoked), it is left as-is. - This ensures that an admin-revoked role is NOT silently reactivated - on every bot restart. + Env config (ADMIN_IDS / ADMIN_EMAILS) is the source of truth for + Superadmin assignments. If a previously revoked assignment exists, + it is reactivated — the env config always wins. - Returns True only if a brand-new assignment was created. + Returns True if a new assignment was created or an inactive one was reactivated. """ result = await db.execute( select(UserRole).where( @@ -325,16 +395,18 @@ async def _assign_if_missing( user_id=user_id, identifier=identifier, ) - else: - logger.info( - 'Superadmin role was previously revoked, not reactivating ' - '(remove user from ADMIN_IDS to stop this warning, ' - 'or re-assign via cabinet)', - user_id=user_id, - identifier=identifier, - user_role_id=existing.id, - ) - return False + return False + + # Reactivate: env config is the source of truth + existing.is_active = True + await db.flush() + logger.info( + 'Reactivated Superadmin role (user is in env config)', + user_id=user_id, + identifier=identifier, + user_role_id=existing.id, + ) + return True user_role = UserRole( user_id=user_id, From de91d3282ffa15c0cec60c0d62871d39e7ee4c05 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:07:29 +0300 Subject: [PATCH 02/11] feat: add subscription status to referral network graph nodes Add subscription_status field (trial_active, paid_active, trial_expired, paid_expired) to NetworkUserNode and NetworkUserDetail schemas. Backend computes status from Subscription.is_trial and end_date using window function to pick latest subscription per user. --- app/cabinet/routes/admin_referral_network.py | 79 ++++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index 10fecfc7..4c5bd900 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -2,6 +2,7 @@ import re from collections import defaultdict +from datetime import UTC, datetime import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -78,6 +79,7 @@ class NetworkUserNode(BaseModel): personal_spent_kopeks: int subscription_name: str | None subscription_end: str | None + subscription_status: str | None registered_at: str | None @@ -134,6 +136,7 @@ class NetworkUserDetail(BaseModel): personal_spent_kopeks: int subscription_name: str | None subscription_end: str | None + subscription_status: str | None registered_at: str | None @@ -215,6 +218,7 @@ def _build_user_node( campaign_id: int | None, subscription_name: str | None, subscription_end_str: str | None, + subscription_status: str | None, ) -> NetworkUserNode: return NetworkUserNode( id=user.id, @@ -232,6 +236,7 @@ def _build_user_node( personal_spent_kopeks=personal_spent, subscription_name=subscription_name, subscription_end=subscription_end_str, + subscription_status=subscription_status, registered_at=_format_datetime(user.created_at), ) @@ -376,18 +381,59 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N return {row[0]: row[1] for row in result} -async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]: - """Return {user_id: (tariff_name, end_date_iso)} for given users.""" +async def _fetch_subscription_info( + db: AsyncSession, user_ids: set[int], +) -> dict[int, tuple[str | None, str | None, str | None]]: + """Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users.""" if not user_ids: return {} - stmt = ( - select(Subscription.user_id, Tariff.name, Subscription.end_date) + row_num = ( + func.row_number() + .over( + partition_by=Subscription.user_id, + order_by=Subscription.end_date.desc().nullslast(), + ) + .label('rn') + ) + + inner = ( + select( + Subscription.user_id, + Tariff.name, + Subscription.end_date, + Subscription.is_trial, + row_num, + ) .outerjoin(Tariff, Subscription.tariff_id == Tariff.id) .where(Subscription.user_id.in_(user_ids)) ) + subq = inner.subquery() + + stmt = select( + subq.c.user_id, + subq.c.name, + subq.c.end_date, + subq.c.is_trial, + ).where(subq.c.rn == 1) + result = await db.execute(stmt) - return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result} + now = datetime.now(UTC) + out: dict[int, tuple[str | None, str | None, str | None]] = {} + for row in result: + user_id, tariff_name, end_date, is_trial = row + end_date_iso = _format_datetime(end_date) if end_date else None + + if is_trial is None: + sub_status = None + elif is_trial: + sub_status = 'trial_active' if (end_date and end_date > now) else 'trial_expired' + else: + sub_status = 'paid_active' if (end_date and end_date > now) else 'paid_expired' + + out[user_id] = (tariff_name, end_date_iso, sub_status) + + return out async def _fetch_campaign_stats( @@ -567,7 +613,7 @@ async def get_referral_network( # Build user nodes user_nodes: list[NetworkUserNode] = [] for user in users: - sub = sub_info.get(user.id, (None, None)) + sub = sub_info.get(user.id, (None, None, None)) user_nodes.append( _build_user_node( user, @@ -578,6 +624,7 @@ async def get_referral_network( campaign_id=campaign_regs.get(user.id), subscription_name=sub[0], subscription_end_str=sub[1], + subscription_status=sub[2], ) ) @@ -816,7 +863,7 @@ async def _build_scoped_graph( user_nodes: list[NetworkUserNode] = [] for user in users: - sub = sub_info.get(user.id, (None, None)) + sub = sub_info.get(user.id, (None, None, None)) user_nodes.append( _build_user_node( user, @@ -827,6 +874,7 @@ async def _build_scoped_graph( campaign_id=campaign_regs.get(user.id), subscription_name=sub[0], subscription_end_str=sub[1], + subscription_status=sub[2], ) ) @@ -1124,10 +1172,23 @@ async def get_network_user_detail( # Subscription info subscription_name: str | None = None subscription_end: str | None = None + subscription_status: str | None = None if user.subscription is not None: if user.subscription.tariff is not None: subscription_name = user.subscription.tariff.name subscription_end = _format_datetime(user.subscription.end_date) + # Compute subscription status + now = datetime.now(UTC) + if user.subscription.is_trial is None: + subscription_status = None + elif user.subscription.is_trial: + subscription_status = ( + 'trial_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'trial_expired' + ) + else: + subscription_status = ( + 'paid_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'paid_expired' + ) return NetworkUserDetail( id=user.id, @@ -1147,6 +1208,7 @@ async def get_network_user_detail( personal_spent_kopeks=personal_spent, subscription_name=subscription_name, subscription_end=subscription_end, + subscription_status=subscription_status, registered_at=_format_datetime(user.created_at), ) @@ -1336,7 +1398,7 @@ async def search_referral_network( sub_info = await _fetch_subscription_info(db, matched_ids) for user in matched_users: - sub = sub_info.get(user.id, (None, None)) + sub = sub_info.get(user.id, (None, None, None)) user_nodes.append( _build_user_node( user, @@ -1347,6 +1409,7 @@ async def search_referral_network( campaign_id=campaign_regs.get(user.id), subscription_name=sub[0], subscription_end_str=sub[1], + subscription_status=sub[2], ) ) From 454dc9321bb9405c5ff0ff559ae4ced15533f3af Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:51:59 +0300 Subject: [PATCH 03/11] fix: consider subscription status field in network graph The subscription_status computation now checks the Subscription.status field. Disabled and pending subscriptions are treated as expired regardless of end_date, preventing incorrect "active" display. Also added SubscriptionStatus import. --- app/cabinet/routes/admin_referral_network.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index 4c5bd900..94f33635 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -17,6 +17,7 @@ from app.database.models import ( PartnerStatus, ReferralEarning, Subscription, + SubscriptionStatus, Tariff, Transaction, TransactionType, @@ -403,6 +404,7 @@ async def _fetch_subscription_info( Tariff.name, Subscription.end_date, Subscription.is_trial, + Subscription.status, row_num, ) .outerjoin(Tariff, Subscription.tariff_id == Tariff.id) @@ -415,17 +417,23 @@ async def _fetch_subscription_info( subq.c.name, subq.c.end_date, subq.c.is_trial, + subq.c.status, ).where(subq.c.rn == 1) result = await db.execute(stmt) now = datetime.now(UTC) out: dict[int, tuple[str | None, str | None, str | None]] = {} for row in result: - user_id, tariff_name, end_date, is_trial = row + user_id, tariff_name, end_date, is_trial, db_status = row end_date_iso = _format_datetime(end_date) if end_date else None if is_trial is None: sub_status = None + elif db_status in ( + SubscriptionStatus.DISABLED.value, + SubscriptionStatus.PENDING.value, + ): + sub_status = 'trial_expired' if is_trial else 'paid_expired' elif is_trial: sub_status = 'trial_active' if (end_date and end_date > now) else 'trial_expired' else: @@ -1181,6 +1189,11 @@ async def get_network_user_detail( now = datetime.now(UTC) if user.subscription.is_trial is None: subscription_status = None + elif user.subscription.status in ( + SubscriptionStatus.DISABLED.value, + SubscriptionStatus.PENDING.value, + ): + subscription_status = 'trial_expired' if user.subscription.is_trial else 'paid_expired' elif user.subscription.is_trial: subscription_status = ( 'trial_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'trial_expired' From 5ed2f0c95842a43ab57220dc05ca346748bd6adb Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 09:54:59 +0300 Subject: [PATCH 04/11] fix: treat expired and limited subscription statuses as inactive in referral network graph Previously only disabled and pending statuses were forced to show as expired in the network graph. Subscriptions with status='expired' or status='limited' but with end_date > now would incorrectly display as active. Now all four non-active statuses are treated as expired. --- app/cabinet/routes/admin_referral_network.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index 94f33635..f7505675 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -432,6 +432,8 @@ async def _fetch_subscription_info( elif db_status in ( SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value, + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.LIMITED.value, ): sub_status = 'trial_expired' if is_trial else 'paid_expired' elif is_trial: @@ -1192,6 +1194,8 @@ async def get_network_user_detail( elif user.subscription.status in ( SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value, + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.LIMITED.value, ): subscription_status = 'trial_expired' if user.subscription.is_trial else 'paid_expired' elif user.subscription.is_trial: From 8b8f1b91f37f829528f785a40e3a9cb98c85e043 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:08:50 +0300 Subject: [PATCH 05/11] refactor: extract _compute_subscription_status shared helper Eliminates duplicated status mapping logic between _fetch_subscription_info and get_network_user_detail. Single source of truth for mapping subscription fields to frontend status labels. --- app/cabinet/routes/admin_referral_network.py | 67 ++++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index f7505675..ef4313c6 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -382,6 +382,32 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N return {row[0]: row[1] for row in result} +def _compute_subscription_status( + is_trial: bool | None, + db_status: str | None, + end_date: datetime | None, + now: datetime, +) -> str | None: + """Map subscription fields to a frontend status label. + + Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None. + Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date. + ACTIVE and TRIAL fall through to a date-based check. + """ + if is_trial is None: + return None + if db_status in ( + SubscriptionStatus.DISABLED.value, + SubscriptionStatus.PENDING.value, + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.LIMITED.value, + ): + return 'trial_expired' if is_trial else 'paid_expired' + if is_trial: + return 'trial_active' if (end_date and end_date > now) else 'trial_expired' + return 'paid_active' if (end_date and end_date > now) else 'paid_expired' + + async def _fetch_subscription_info( db: AsyncSession, user_ids: set[int], ) -> dict[int, tuple[str | None, str | None, str | None]]: @@ -426,21 +452,7 @@ async def _fetch_subscription_info( for row in result: user_id, tariff_name, end_date, is_trial, db_status = row end_date_iso = _format_datetime(end_date) if end_date else None - - if is_trial is None: - sub_status = None - elif db_status in ( - SubscriptionStatus.DISABLED.value, - SubscriptionStatus.PENDING.value, - SubscriptionStatus.EXPIRED.value, - SubscriptionStatus.LIMITED.value, - ): - sub_status = 'trial_expired' if is_trial else 'paid_expired' - elif is_trial: - sub_status = 'trial_active' if (end_date and end_date > now) else 'trial_expired' - else: - sub_status = 'paid_active' if (end_date and end_date > now) else 'paid_expired' - + sub_status = _compute_subscription_status(is_trial, db_status, end_date, now) out[user_id] = (tariff_name, end_date_iso, sub_status) return out @@ -1187,25 +1199,12 @@ async def get_network_user_detail( if user.subscription.tariff is not None: subscription_name = user.subscription.tariff.name subscription_end = _format_datetime(user.subscription.end_date) - # Compute subscription status - now = datetime.now(UTC) - if user.subscription.is_trial is None: - subscription_status = None - elif user.subscription.status in ( - SubscriptionStatus.DISABLED.value, - SubscriptionStatus.PENDING.value, - SubscriptionStatus.EXPIRED.value, - SubscriptionStatus.LIMITED.value, - ): - subscription_status = 'trial_expired' if user.subscription.is_trial else 'paid_expired' - elif user.subscription.is_trial: - subscription_status = ( - 'trial_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'trial_expired' - ) - else: - subscription_status = ( - 'paid_active' if (user.subscription.end_date and user.subscription.end_date > now) else 'paid_expired' - ) + subscription_status = _compute_subscription_status( + user.subscription.is_trial, + user.subscription.status, + user.subscription.end_date, + datetime.now(UTC), + ) return NetworkUserDetail( id=user.id, From 2bdb7643f8fd142e99caee0fe989348161377348 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:31:18 +0300 Subject: [PATCH 06/11] feat: add total subscription revenue to referral network stats Expose total_subscription_revenue_kopeks in NetworkGraphResponse, computed from the existing personal_spent data (sum of all SUBSCRIPTION_PAYMENT transactions by scoped users). --- app/cabinet/routes/admin_referral_network.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index ef4313c6..7d7f5740 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -117,6 +117,7 @@ class NetworkGraphResponse(BaseModel): total_referrers: int total_campaigns: int total_earnings_kopeks: int + total_subscription_revenue_kopeks: int class NetworkUserDetail(BaseModel): @@ -608,6 +609,7 @@ async def get_referral_network( total_referrers=0, total_campaigns=0, total_earnings_kopeks=0, + total_subscription_revenue_kopeks=0, ) # Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability) @@ -698,6 +700,7 @@ async def get_referral_network( total_referrers = len([u for u in user_nodes if u.direct_referrals > 0]) total_earnings = sum(personal_revenue.values()) + total_subscription_revenue = sum(personal_spent.values()) return NetworkGraphResponse( users=user_nodes, @@ -707,6 +710,7 @@ async def get_referral_network( total_referrers=total_referrers, total_campaigns=len(campaign_nodes), total_earnings_kopeks=total_earnings, + total_subscription_revenue_kopeks=total_subscription_revenue, ) @@ -862,6 +866,7 @@ async def _build_scoped_graph( total_referrers=0, total_campaigns=0, total_earnings_kopeks=0, + total_subscription_revenue_kopeks=0, ) # Cap to prevent excessive response sizes @@ -948,6 +953,7 @@ async def _build_scoped_graph( total_referrers = len([u for u in user_nodes if u.direct_referrals > 0]) total_earnings = sum(personal_revenue.values()) + total_subscription_revenue = sum(personal_spent.values()) return NetworkGraphResponse( users=user_nodes, @@ -957,6 +963,7 @@ async def _build_scoped_graph( total_referrers=total_referrers, total_campaigns=len(campaign_nodes), total_earnings_kopeks=total_earnings, + total_subscription_revenue_kopeks=total_subscription_revenue, ) From 056c13bc23e6737f44bbcb802a66b643349f75a9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:36:13 +0300 Subject: [PATCH 07/11] fix: use abs() for subscription payment amounts in referral network SUBSCRIPTION_PAYMENT transactions are stored as negative values, causing negative totals in stats panel and user detail card. --- app/cabinet/routes/admin_referral_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index 7d7f5740..e385db6b 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -340,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in return {} stmt = ( - select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)) .where( and_( Transaction.user_id.in_(user_ids), @@ -1134,7 +1134,7 @@ async def get_network_user_detail( branch_revenue = 0 # Personal spent - spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( Transaction.user_id == user_id, Transaction.type.in_(SPENT_TRANSACTION_TYPES), From 1eb4e18c1776b2265a48e0b923a0ca4ee057d912 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:39:20 +0300 Subject: [PATCH 08/11] fix: add abs() to all remaining subscription payment sum queries Apply func.abs() consistently to all 5 remaining locations that sum SUBSCRIPTION_PAYMENT amounts: branch revenue, campaign stats, user detail branch revenue, campaign detail, and search results. --- app/cabinet/routes/admin_referral_network.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index e385db6b..a10e6aff 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -319,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in stmt = ( select( referred_user.c.referred_by_id, - func.coalesce(func.sum(Transaction.amount_kopeks), 0), + func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0), ) .join(referred_user, Transaction.user_id == referred_user.c.id) .where( @@ -497,7 +497,7 @@ async def _fetch_campaign_stats( user_spent: dict[int, int] = {} if all_campaign_users: spent_stmt = ( - select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)) .where( and_( Transaction.user_id.in_(all_campaign_users), @@ -1179,7 +1179,7 @@ async def get_network_user_detail( # Branch revenue: total spent by all users in the branch branch_user_ids_stmt = select(branch_cte.c.id) - branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where( and_( Transaction.user_id.in_(branch_user_ids_stmt), Transaction.type.in_(SPENT_TRANSACTION_TYPES), @@ -1300,7 +1300,7 @@ async def get_network_campaign_detail( total_spent = 0 if campaign_user_ids: spent_stmt = ( - select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)) .where( and_( Transaction.user_id.in_(campaign_user_ids), @@ -1486,7 +1486,7 @@ async def search_referral_network( campaign_user_spent: dict[int, int] = {} if all_campaign_user_ids: spent_stmt = ( - select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)) .where( and_( Transaction.user_id.in_(all_campaign_user_ids), From bcc761f9d3f673bd2b404adf817762058d8e0df4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:46:08 +0300 Subject: [PATCH 09/11] fix: add missing total_subscription_revenue_kopeks in scoped graph early return Prevents Pydantic ValidationError (500) when scoped_user_ids is empty but campaign_ids is present. --- app/cabinet/routes/admin_referral_network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index a10e6aff..6eef7134 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -857,6 +857,7 @@ async def _build_scoped_graph( total_referrers=0, total_campaigns=len(campaign_nodes), total_earnings_kopeks=0, + total_subscription_revenue_kopeks=0, ) return NetworkGraphResponse( users=[], From 8ac1183670cc03c875aa4e670e81e3e4936350dd Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:47:01 +0300 Subject: [PATCH 10/11] chore: ruff format admin_referral_network.py --- app/cabinet/routes/admin_referral_network.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_referral_network.py b/app/cabinet/routes/admin_referral_network.py index 6eef7134..40f65354 100644 --- a/app/cabinet/routes/admin_referral_network.py +++ b/app/cabinet/routes/admin_referral_network.py @@ -410,7 +410,8 @@ def _compute_subscription_status( async def _fetch_subscription_info( - db: AsyncSession, user_ids: set[int], + db: AsyncSession, + user_ids: set[int], ) -> dict[int, tuple[str | None, str | None, str | None]]: """Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users.""" if not user_ids: From 0335f40b47f39ac006a4010469fd76b3c3d54dad Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 10:51:26 +0300 Subject: [PATCH 11/11] chore: ruff format rbac_bootstrap_service.py --- app/services/rbac_bootstrap_service.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py index 4f6763ad..5a55ad93 100644 --- a/app/services/rbac_bootstrap_service.py +++ b/app/services/rbac_bootstrap_service.py @@ -218,7 +218,10 @@ async def bootstrap_superadmins(db: AsyncSession) -> None: # ── 4. Revoke superadmin from users NOT in env ─────────────── revoked_count = await _revoke_stale_superadmins( - db, role_id=role_id, admin_ids=admin_ids, admin_emails=admin_emails, + db, + role_id=role_id, + admin_ids=admin_ids, + admin_emails=admin_emails, ) # ── 5. Commit all changes ────────────────────────────────────── @@ -279,11 +282,7 @@ async def _revoke_stale_superadmins( # Check if user is still in env config. # email_verified is required — symmetric with _ensure_role_by_email. in_env_by_id = user.telegram_id is not None and user.telegram_id in admin_ids_set - in_env_by_email = ( - user.email is not None - and user.email_verified - and user.email.lower() in admin_emails_set - ) + in_env_by_email = user.email is not None and user.email_verified and user.email.lower() in admin_emails_set if not in_env_by_id and not in_env_by_email: assignment.is_active = False