Compare commits

...

14 Commits

Author SHA1 Message Date
Egor df9985802b Merge pull request #2801 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.41.0
2026-03-22 10:56:39 +03:00
github-actions[bot] c66849db10 chore(main): release 3.41.0 2026-03-22 07:56:12 +00:00
Egor 8a1da85f3e Merge pull request #2800 from BEDOLAGA-DEV/dev
Dev
2026-03-22 10:55:46 +03:00
Fringg 0335f40b47 chore: ruff format rbac_bootstrap_service.py 2026-03-22 10:51:26 +03:00
Fringg 8ac1183670 chore: ruff format admin_referral_network.py 2026-03-22 10:47:01 +03:00
Fringg bcc761f9d3 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.
2026-03-22 10:46:08 +03:00
Fringg 1eb4e18c17 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.
2026-03-22 10:39:20 +03:00
Fringg 056c13bc23 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.
2026-03-22 10:36:13 +03:00
Fringg 2bdb7643f8 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).
2026-03-22 10:31:18 +03:00
Fringg 8b8f1b91f3 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.
2026-03-22 10:08:50 +03:00
Fringg 5ed2f0c958 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.
2026-03-22 09:54:59 +03:00
Fringg 454dc9321b 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.
2026-03-22 09:51:59 +03:00
Fringg de91d3282f 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.
2026-03-22 09:07:29 +03:00
Fringg e0bedc8e78 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).
2026-03-22 08:41:36 +03:00
7 changed files with 240 additions and 84 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.40.0"
".": "3.41.0"
}
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
### New Features
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
### Bug Fixes
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
### Refactoring
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.40.0" # x-release-please-version
ARG VERSION="v3.41.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+103 -15
View File
@@ -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
@@ -16,6 +17,7 @@ from app.database.models import (
PartnerStatus,
ReferralEarning,
Subscription,
SubscriptionStatus,
Tariff,
Transaction,
TransactionType,
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
personal_spent_kopeks: int
subscription_name: str | None
subscription_end: str | None
subscription_status: str | None
registered_at: str | None
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
total_referrers: int
total_campaigns: int
total_earnings_kopeks: int
total_subscription_revenue_kopeks: int
class NetworkUserDetail(BaseModel):
@@ -134,6 +138,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 +220,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 +238,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),
)
@@ -312,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(
@@ -333,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),
@@ -376,18 +383,81 @@ 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."""
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]]:
"""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,
Subscription.status,
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,
subq.c.status,
).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, db_status = row
end_date_iso = _format_datetime(end_date) if end_date else None
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
out[user_id] = (tariff_name, end_date_iso, sub_status)
return out
async def _fetch_campaign_stats(
@@ -428,7 +498,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),
@@ -540,6 +610,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)
@@ -567,7 +638,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 +649,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],
)
)
@@ -629,6 +701,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,
@@ -638,6 +711,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,
)
@@ -784,6 +858,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=[],
@@ -793,6 +868,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
@@ -816,7 +892,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 +903,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],
)
)
@@ -878,6 +955,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,
@@ -887,6 +965,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,
)
@@ -1057,7 +1136,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),
@@ -1102,7 +1181,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),
@@ -1124,10 +1203,17 @@ 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)
subscription_status = _compute_subscription_status(
user.subscription.is_trial,
user.subscription.status,
user.subscription.end_date,
datetime.now(UTC),
)
return NetworkUserDetail(
id=user.id,
@@ -1147,6 +1233,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),
)
@@ -1215,7 +1302,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),
@@ -1336,7 +1423,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 +1434,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],
)
)
@@ -1400,7 +1488,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),
+18 -44
View File
@@ -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}
+93 -22
View File
@@ -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,28 @@ 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 +245,60 @@ 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 +345,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 +371,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 +394,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,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.40.0"
version = "3.41.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }