Compare commits

...

24 Commits

Author SHA1 Message Date
Egor 056070b6a4 Merge pull request #2578 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.8.0
2026-02-08 23:36:16 +03:00
github-actions[bot] 8b53c73ce8 chore(main): release 3.8.0 2026-02-08 20:35:58 +00:00
Egor e6ebf81752 Merge pull request #2577 from BEDOLAGA-DEV/dev
feat: admin panel enhancements & bug fixes
2026-02-08 23:35:17 +03:00
Fringg 11b8ab1959 feat: add admin updates endpoint for bot and cabinet releases
GET /cabinet/admin/updates/releases returns release history
and version info for both projects from GitHub API with caching.
2026-02-08 23:20:47 +03:00
Fringg 17e9259eb1 fix: include additional devices in tariff renewal price and display
Tariff renewal showed tariff.device_limit (default) instead of
subscription.device_limit (actual) and didn't add extra device
cost to the renewal price. Fixed in show_tariff_extend,
select_tariff_extend_period, and confirm_tariff_extend.
2026-02-08 23:01:11 +03:00
Fringg 02c30f8e7e feat: add system info endpoint for admin dashboard
Exposes bot version, Python version, uptime, total users and active
subscriptions via GET /cabinet/admin/stats/system-info.
2026-02-08 22:52:12 +03:00
Fringg 15c7cc2a58 feat: add server-side sorting for enrichment columns 2026-02-08 22:39:25 +03:00
Fringg f2dbab6171 feat: add enrichment data to CSV export
Extract _build_enrichment() helper, reuse in both GET /enrichment
endpoint and CSV export. CSV now includes: Connected Devices,
Total Spent (RUB), Sub Start, Sub End, Last Node columns.
2026-02-08 22:36:45 +03:00
Fringg 17af51ce0b fix: use correct pagination params (start/size) for bulk HWID devices
Remnawave API uses start/size (not take/skip) with default size=25.
Now fetches all devices with size=1000 per page. Remove debug logging.
2026-02-08 22:32:20 +03:00
Fringg 8f7fa76e6a fix: revert device pagination, add raw user data field discovery
Bulk device endpoint ignores take/skip params, causing duplicates.
Revert to single call. Add logging to discover extra fields in
panel user response that might include device count.
2026-02-08 22:26:06 +03:00
Fringg 4648a82da9 fix: paginate bulk device endpoint to fetch all HWID devices
The GET /api/hwid/devices endpoint returns only 25 devices by default.
Add take/skip pagination to fetch all devices across all pages.
2026-02-08 22:21:55 +03:00
Fringg 5be82f2d78 fix: add enrichment device mapping debug logs 2026-02-08 22:18:46 +03:00
Fringg 9e3aa23f69 chore: remove debug logging from enrichment endpoint 2026-02-08 22:14:35 +03:00
Fringg 46da31d89c fix: add debug logging for bulk device response structure 2026-02-08 22:11:54 +03:00
Fringg 5f219c33e6 fix: use bulk device endpoint instead of per-user calls
Replace O(users) per-user GET /api/hwid/devices/{uuid} calls
with single GET /api/hwid/devices bulk call to avoid rate limiting.
2026-02-08 22:06:15 +03:00
Fringg 94fcf20d17 fix: add email field to traffic table for OAuth/email users
Include user email in UserTrafficItem schema, search filter,
CSV export, and frontend display (shown below name when no
Telegram username exists).
2026-02-08 22:04:42 +03:00
Fringg 9d39901f78 fix: use per-user panel endpoints for reliable device counts and last node data
Replace bulk /api/hwid/devices and /api/subscriptions calls with
proven per-user endpoints: get_all_users() (paginated) for last
connected node and get_user_devices() with semaphore for device counts.
2026-02-08 22:01:32 +03:00
Fringg 5cf3f2f76e feat: add traffic usage enrichment endpoint with devices, spending, dates, last node
Add GET /admin/traffic/enrichment that returns per-user enrichment data
(connected devices, total spending, subscription dates, last connected node)
via bulk panel API calls with 5-min server-side cache.
2026-02-08 21:49:42 +03:00
Fringg 2f90f9134d feat: add admin traffic packages and device limit management
Add TrafficPurchaseItem schema, extend subscription info with traffic
purchases, add add_traffic/remove_traffic/set_device_limit actions,
extend tariff builder with device/traffic config fields.
2026-02-08 21:13:44 +03:00
Fringg c57de1081a feat: add admin device management endpoints
Add GET/DELETE endpoints for managing user devices from admin panel:
- GET /{user_id}/devices - list connected devices
- DELETE /{user_id}/devices/{hwid} - remove single device
- DELETE /{user_id}/devices - reset all devices
2026-02-08 20:49:04 +03:00
Fringg 33d5155a8d style: format schemas and remnawave_service with ruff 2026-02-08 20:39:22 +03:00
Fringg 9828ff0845 fix: read bot version from pyproject.toml when VERSION env is not set
Previously the bot only checked os.getenv('VERSION'), returning
'UNKNOW' when unset. Now falls back to importlib.metadata and
direct pyproject.toml parsing, so the version stays correct after
release-please updates it.
2026-02-08 20:38:17 +03:00
Fringg da6f746b09 feat: add endpoint for updating user referral commission percent
POST /{user_id}/referral-commission allows admins to set individual
referral commission percentage (0-100) or null for system default.
2026-02-08 20:29:53 +03:00
Fringg 165965d8ea fix: add email/UUID fallback for OAuth user panel sync
OAuth users registering via cabinet have no telegram_id, causing
panel sync failures. All RemnaWave panel lookups now use a 3-level
chain: UUID → telegram_id → email. Also pass email and user_id to
format_remnawave_username to generate unique panel usernames.
2026-02-08 19:55:34 +03:00
18 changed files with 1037 additions and 67 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.7.2"
".": "3.8.0"
}
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
### New Features
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
### Bug Fixes
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.7.2" # x-release-please-version
ARG VERSION="v3.8.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+2
View File
@@ -18,6 +18,7 @@ from .admin_stats import router as admin_stats_router
from .admin_tariffs import router as admin_tariffs_router
from .admin_tickets import router as admin_tickets_router
from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .auth import router as auth_router
@@ -86,6 +87,7 @@ router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
# WebSocket route
+47
View File
@@ -1,6 +1,8 @@
"""Admin routes for statistics dashboard in cabinet."""
import logging
import sys
import time
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
@@ -22,12 +24,15 @@ from app.database.models import (
User,
)
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
logger = logging.getLogger(__name__)
_start_time = time.time()
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
tariff_stats: TariffStats | None = None
class SystemInfoResponse(BaseModel):
"""System information for admin dashboard."""
bot_version: str
python_version: str
uptime_seconds: int
users_total: int
subscriptions_active: int
# ============ Extended Stats Schemas ============
@@ -309,6 +324,38 @@ async def get_dashboard_stats(
)
@router.get('/system-info', response_model=SystemInfoResponse)
async def get_system_info(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get system information for admin dashboard."""
try:
users_total_result = await db.execute(select(func.count()).select_from(User))
users_total = users_total_result.scalar() or 0
subs_active_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
subscriptions_active = subs_active_result.scalar() or 0
return SystemInfoResponse(
bot_version=version_service.current_version,
python_version=sys.version.split()[0],
uptime_seconds=int(time.time() - _start_time),
users_total=users_total,
subscriptions_active=subscriptions_active,
)
except Exception as e:
logger.error(f'Failed to get system info: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load system information',
)
@router.get('/nodes', response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
+192 -7
View File
@@ -12,20 +12,22 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import Subscription, User
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 ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
UserTrafficEnrichment,
UserTrafficItem,
)
@@ -44,6 +46,7 @@ _cache_lock = asyncio.Lock()
# Valid sort fields for the GET endpoint
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
def _get_status(sub) -> str | None:
@@ -186,9 +189,14 @@ def _build_traffic_items(
full_name = user.full_name
username = user.username
email = user.email
if search_lower:
if search_lower not in (full_name or '').lower() and search_lower not in (username or '').lower():
if (
search_lower not in (full_name or '').lower()
and search_lower not in (username or '').lower()
and search_lower not in (email or '').lower()
):
continue
sub = user.subscription
@@ -223,6 +231,7 @@ def _build_traffic_items(
user_id=user.id,
telegram_id=user.telegram_id,
username=username,
email=email,
full_name=full_name,
tariff_name=tariff_name,
subscription_status=subscription_status,
@@ -322,15 +331,31 @@ async def get_traffic_usage(
if not node_filter:
node_filter = None # No valid nodes matched, treat as "all nodes"
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
if sort_by not in _SORT_FIELDS and not is_node_sort:
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
sort_by = 'total_bytes'
# For enrichment sort, build items unsorted then sort by enrichment field
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
items = _build_traffic_items(
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
)
if is_enrichment_sort:
enrichment_data = await _build_enrichment(db, user_map)
enr_key_map = {
'connected': lambda e: e.devices_connected,
'total_spent': lambda e: e.total_spent_kopeks,
'sub_start': lambda e: e.subscription_start_date or '',
'sub_end': lambda e: e.subscription_end_date or '',
'last_node': lambda e: e.last_node_name or '',
}
key_fn = enr_key_map[sort_by]
empty = UserTrafficEnrichment()
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
total = len(items)
paginated = items[offset : offset + limit]
@@ -346,6 +371,156 @@ async def get_traffic_usage(
)
# ============== Enrichment endpoint ==============
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
_enrichment_lock = asyncio.Lock()
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
"""Get total spent kopeks for multiple users in a single query."""
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
.group_by(Transaction.user_id)
)
return {row[0]: int(row[1]) for row in result.all()}
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
"""Build enrichment data for all users: devices, spending, dates, last node."""
uuid_to_user_id: dict[str, int] = {}
for uuid, user in user_map.items():
uuid_to_user_id[uuid] = user.id
service = RemnaWaveService()
devices_by_user: dict[int, int] = {}
last_node_uuid_by_user: dict[int, str] = {}
node_uuid_to_name: dict[str, str] = {}
if service.is_configured:
async with service.get_api_client() as api:
# 3 bulk calls: nodes + users (paginated) + devices
try:
nodes_list = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
nodes_list = []
for node in nodes_list:
node_uuid_to_name[node.uuid] = node.name
# Fetch all panel users (paginated) for last connected node
panel_users = []
try:
first_page = await api.get_all_users(start=0, size=500)
panel_users.extend(first_page['users'])
total_panel = first_page['total']
if total_panel > 500:
remaining_tasks = [
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
]
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
for page in pages:
if isinstance(page, dict):
panel_users.extend(page['users'])
except Exception:
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
for pu in panel_users:
uid = uuid_to_user_id.get(pu.uuid)
if uid is None:
continue
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
# Bulk device fetch — single API call (paginated with start/size)
try:
devices_data = await api.get_all_hwid_devices()
for device in devices_data.get('devices', []):
user_uuid = device.get('userUuid', '')
uid = uuid_to_user_id.get(user_uuid)
if uid is not None:
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
except Exception:
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
# Bulk spending stats
all_user_ids = [u.id for u in user_map.values()]
spending_map = await _get_bulk_spending(db, all_user_ids)
# Build enrichment data
enrichment: dict[int, UserTrafficEnrichment] = {}
for uuid, user in user_map.items():
uid = user.id
sub = user.subscription
start_date = None
end_date = None
if sub:
if sub.start_date:
start_date = sub.start_date.isoformat()
if sub.end_date:
end_date = sub.end_date.isoformat()
last_node_name = None
last_uuid = last_node_uuid_by_user.get(uid)
if last_uuid:
last_node_name = node_uuid_to_name.get(last_uuid)
enrichment[uid] = UserTrafficEnrichment(
devices_connected=devices_by_user.get(uid, 0),
total_spent_kopeks=spending_map.get(uid, 0),
subscription_start_date=start_date,
subscription_end_date=end_date,
last_node_name=last_node_name,
)
return enrichment
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
cache_key = 'enrichment'
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
async with _enrichment_lock:
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
user_map = await _load_user_map(db)
enrichment = await _build_enrichment(db, user_map)
_enrichment_cache[cache_key] = (now, enrichment)
# Evict expired
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
for k in expired:
del _enrichment_cache[k]
return TrafficEnrichmentResponse(data=enrichment)
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
@@ -386,6 +561,7 @@ async def export_traffic_csv(
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
enrichment = await _build_enrichment(db, user_map)
# Parse filters
tariff_filter: set[str] | None = None
@@ -434,12 +610,21 @@ async def export_traffic_csv(
'User ID': item.user_id,
'Telegram ID': item.telegram_id or '',
'Username': item.username or '',
'Email': item.email or '',
'Full Name': item.full_name,
'Tariff': item.tariff_name or '',
'Status': item.subscription_status or '',
'Traffic Limit (GB)': item.traffic_limit_gb,
'Devices': item.device_limit,
'Device Limit': item.device_limit,
}
# Enrichment columns
enr = enrichment.get(item.user_id)
row['Connected Devices'] = enr.devices_connected if enr else 0
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
row['Last Node'] = enr.last_node_name or '' if enr else ''
for node in csv_nodes:
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
row['Total (bytes)'] = item.total_bytes
+139
View File
@@ -0,0 +1,139 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
import aiohttp
from fastapi import APIRouter, Depends
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
# ============ Schemas ============
class ReleaseItem(BaseModel):
tag_name: str
name: str
body: str
published_at: str
prerelease: bool
class ProjectReleasesInfo(BaseModel):
current_version: str
has_updates: bool
releases: list[ReleaseItem]
repo_url: str
class ReleasesResponse(BaseModel):
bot: ProjectReleasesInfo
cabinet: ProjectReleasesInfo
# ============ Cabinet releases cache ============
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
_cabinet_cache: dict = {}
_cabinet_last_check: datetime | None = None
_CACHE_TTL = 3600
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for item in data[:20]:
releases.append(
{
'tag_name': item['tag_name'],
'name': item.get('name') or item['tag_name'],
'body': item.get('body') or '',
'published_at': item['published_at'],
'prerelease': item.get('prerelease', False),
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
return _cabinet_cache.get('releases', [])
# ============ Routes ============
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
bot_releases_raw = await version_service._fetch_releases()
has_updates, _ = await version_service.check_for_updates()
bot_releases = [
ReleaseItem(
tag_name=r.tag_name,
name=r.name,
body=r.full_description,
published_at=r.published_at.isoformat(),
prerelease=r.prerelease,
)
for r in bot_releases_raw[:10]
]
bot_info = ProjectReleasesInfo(
current_version=version_service.current_version,
has_updates=has_updates,
releases=bot_releases,
repo_url=f'https://github.com/{version_service.repo}',
)
# Cabinet releases
cabinet_releases_raw = await _fetch_cabinet_releases()
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
# Current version = latest non-prerelease tag
cabinet_current = ''
for r in cabinet_releases_raw:
if not r.get('prerelease', False):
cabinet_current = r['tag_name']
break
cabinet_info = ProjectReleasesInfo(
current_version=cabinet_current,
has_updates=False,
releases=cabinet_releases,
repo_url=f'https://github.com/{CABINET_REPO}',
)
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
+420 -27
View File
@@ -28,6 +28,7 @@ from app.database.models import (
PromoGroup,
Subscription,
SubscriptionStatus,
TrafficPurchase,
Transaction,
TransactionType,
User,
@@ -37,8 +38,10 @@ from app.utils.timezone import panel_datetime_to_naive_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
DeleteDeviceResponse,
DeleteUserRequest,
DeleteUserResponse,
DeviceInfo,
DisableUserRequest,
DisableUserResponse,
FullDeleteUserRequest,
@@ -46,6 +49,7 @@ from ..schemas.users import (
PanelSyncStatusResponse,
PanelUserInfo,
PeriodPriceInfo,
ResetDevicesResponse,
ResetSubscriptionRequest,
ResetSubscriptionResponse,
ResetTrialRequest,
@@ -55,10 +59,13 @@ from ..schemas.users import (
SyncFromPanelResponse,
SyncToPanelRequest,
SyncToPanelResponse,
TrafficPurchaseItem,
UpdateBalanceRequest,
UpdateBalanceResponse,
UpdatePromoGroupRequest,
UpdatePromoGroupResponse,
UpdateReferralCommissionRequest,
UpdateReferralCommissionResponse,
UpdateRestrictionsRequest,
UpdateRestrictionsResponse,
UpdateSubscriptionRequest,
@@ -68,6 +75,7 @@ from ..schemas.users import (
UserAvailableTariffItem,
UserAvailableTariffsResponse,
UserDetailResponse,
UserDevicesResponse,
UserListItem,
UserNodeUsageItem,
UserNodeUsageResponse,
@@ -157,13 +165,43 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
async def _build_subscription_info_async(db: AsyncSession, subscription: Subscription) -> UserSubscriptionInfo:
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name asynchronously."""
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name and traffic purchases."""
tariff_name = None
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
tariff_name = tariff.name
return _build_subscription_info(subscription, tariff_name=tariff_name)
# Fetch traffic purchases
now = datetime.utcnow()
tp_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.order_by(TrafficPurchase.created_at.desc())
)
tp_result = await db.execute(tp_query)
purchases = tp_result.scalars().all()
traffic_purchase_items = []
for p in purchases:
delta = p.expires_at - now
days_remaining = max(0, delta.days)
is_expired = now >= p.expires_at
traffic_purchase_items.append(
TrafficPurchaseItem(
id=p.id,
traffic_gb=p.traffic_gb,
expires_at=p.expires_at,
created_at=p.created_at,
days_remaining=days_remaining,
is_expired=is_expired,
)
)
info = _build_subscription_info(subscription, tariff_name=tariff_name)
info.purchased_traffic_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
info.traffic_purchases = traffic_purchase_items
return info
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
@@ -198,12 +236,16 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
description = settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
@@ -213,7 +255,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
async with service.get_api_client() as api:
panel_uuid = user.remnawave_uuid
# Try to find existing user
# Try to find existing user by UUID first
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
panel_uuid = None
user.remnawave_uuid = None
# Fallback: search by telegram_id
if not panel_uuid and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
@@ -221,6 +271,14 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
user.remnawave_uuid = panel_uuid
changes['remnawave_uuid_discovered'] = panel_uuid
# Fallback: search by email (for OAuth users without telegram_id)
if not panel_uuid and user.email:
existing_users = await api.get_user_by_email(user.email)
if existing_users:
panel_uuid = existing_users[0].uuid
user.remnawave_uuid = panel_uuid
changes['remnawave_uuid_discovered'] = panel_uuid
if panel_uuid:
# Update existing user
update_kwargs = {
@@ -256,6 +314,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
'active_internal_squads': subscription.connected_squads or [],
}
@@ -612,15 +671,30 @@ async def get_user_panel_info(
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured or not user.telegram_id:
if not service.is_configured:
return UserPanelInfoResponse(found=False)
async with service.get_api_client() as api:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if not panel_users:
return UserPanelInfoResponse(found=False)
panel_user = None
panel_user = panel_users[0]
# Try by UUID first (works for all users including OAuth)
if user.remnawave_uuid:
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
# Fallback: search by telegram_id
if not panel_user and user.telegram_id:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if panel_users:
panel_user = panel_users[0]
# Fallback: search by email (OAuth users)
if not panel_user and user.email:
panel_users_by_email = await api.get_user_by_email(user.email)
if panel_users_by_email:
panel_user = panel_users_by_email[0]
if not panel_user:
return UserPanelInfoResponse(found=False)
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
last_node_name = None
@@ -1060,6 +1134,113 @@ async def update_user_subscription(
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'add_traffic':
if not request.traffic_gb:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='traffic_gb parameter is required for add_traffic action',
)
from app.database.crud.subscription import add_subscription_traffic
await add_subscription_traffic(db, subscription, request.traffic_gb)
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}')
return UpdateSubscriptionResponse(
success=True,
message=f'Added {request.traffic_gb} GB traffic (30 days)',
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'remove_traffic':
if not request.traffic_purchase_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='traffic_purchase_id parameter is required for remove_traffic action',
)
# Find the traffic purchase
tp_query = select(TrafficPurchase).where(
TrafficPurchase.id == request.traffic_purchase_id,
TrafficPurchase.subscription_id == subscription.id,
)
tp_result = await db.execute(tp_query)
traffic_purchase = tp_result.scalar_one_or_none()
if not traffic_purchase:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Traffic purchase not found',
)
removed_gb = traffic_purchase.traffic_gb
# Decrement counters
subscription.traffic_limit_gb = max(0, subscription.traffic_limit_gb - removed_gb)
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = max(0, current_purchased - removed_gb)
# Delete the purchase record
await db.delete(traffic_purchase)
# Recalculate traffic_reset_at from remaining active purchases
now = datetime.utcnow()
remaining_query = select(TrafficPurchase).where(
TrafficPurchase.subscription_id == subscription.id,
TrafficPurchase.expires_at > now,
TrafficPurchase.id != request.traffic_purchase_id,
)
remaining_result = await db.execute(remaining_query)
remaining_purchases = remaining_result.scalars().all()
if remaining_purchases:
subscription.traffic_reset_at = min(p.expires_at for p in remaining_purchases)
else:
subscription.traffic_reset_at = None
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}'
)
return UpdateSubscriptionResponse(
success=True,
message=f'Removed {removed_gb} GB traffic package',
subscription=await _build_subscription_info_async(db, subscription),
)
if request.action == 'set_device_limit':
if request.device_limit is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='device_limit parameter is required for set_device_limit action',
)
subscription.device_limit = request.device_limit
await db.commit()
await db.refresh(subscription)
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}')
return UpdateSubscriptionResponse(
success=True,
message=f'Device limit set to {request.device_limit}',
subscription=await _build_subscription_info_async(db, subscription),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unknown action: {request.action}',
@@ -1140,6 +1321,11 @@ async def get_user_available_tariffs(
price_per_day_kopeks=tariff.price_per_day_kopeks,
min_days=tariff.min_days,
max_days=tariff.max_days,
device_price_kopeks=tariff.device_price_kopeks,
max_device_limit=tariff.max_device_limit,
traffic_topup_enabled=tariff.traffic_topup_enabled,
traffic_topup_packages=tariff.traffic_topup_packages or {},
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
is_available=is_available,
requires_promo_group=requires_promo_group,
)
@@ -1326,6 +1512,173 @@ async def update_user_promo_group(
)
# === Referral Commission ===
@router.post('/{user_id}/referral-commission', response_model=UpdateReferralCommissionResponse)
async def update_user_referral_commission(
user_id: int,
request: UpdateReferralCommissionRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's individual referral commission percentage."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(
f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}'
)
return UpdateReferralCommissionResponse(
success=True,
old_commission_percent=old_commission,
new_commission_percent=request.commission_percent,
message='Referral commission updated',
)
# === Devices ===
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
async def get_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user devices from Remnawave panel."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
if not user.remnawave_uuid:
return UserDevicesResponse()
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured:
return UserDevicesResponse()
async with service.get_api_client() as api:
response = await api.get_user_devices(user.remnawave_uuid)
devices = []
for d in response.get('devices', []):
hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
if not hwid:
continue
devices.append(
DeviceInfo(
hwid=hwid,
platform=d.get('platform') or d.get('platformType') or '',
device_model=d.get('deviceModel') or d.get('model') or d.get('name') or '',
created_at=d.get('updatedAt') or d.get('lastSeen') or d.get('createdAt'),
)
)
device_limit = 0
if user.subscription:
device_limit = user.subscription.device_limit or 0
return UserDevicesResponse(
devices=devices,
total=response.get('total', len(devices)),
device_limit=device_limit,
)
except Exception as e:
logger.error(f'Error fetching devices for user {user_id}: {e}')
return UserDevicesResponse()
@router.delete('/{user_id}/devices/{hwid}', response_model=DeleteDeviceResponse)
async def delete_user_device(
user_id: int,
hwid: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a single device for user."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
if not user.remnawave_uuid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
success = await api.remove_device(user.remnawave_uuid, hwid)
if success:
logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}')
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
return DeleteDeviceResponse(success=False, message='Failed to delete device')
except Exception as e:
logger.error(f'Error deleting device {hwid} for user {user_id}: {e}')
return DeleteDeviceResponse(success=False, message=str(e))
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
async def reset_user_devices(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all devices for user."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
if not user.remnawave_uuid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
devices_info = await api.get_user_devices(user.remnawave_uuid)
devices = devices_info.get('devices', [])
total = len(devices)
if total == 0:
return ResetDevicesResponse(success=True, message='No devices to reset', deleted_count=0)
deleted = 0
for d in devices:
device_hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
if device_hwid:
try:
await api.remove_device(user.remnawave_uuid, device_hwid)
deleted += 1
except Exception:
pass
logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}')
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
except Exception as e:
logger.error(f'Error resetting devices for user {user_id}: {e}')
return ResetDevicesResponse(success=False, message=str(e))
# === Delete User ===
@@ -1754,11 +2107,27 @@ async def get_user_sync_status(
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if service.is_configured and user.telegram_id:
if service.is_configured:
async with service.get_api_client() as api:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if panel_users:
panel_user = panel_users[0]
panel_user = None
# Try by UUID first (works for all users including OAuth)
if user.remnawave_uuid:
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
# Fallback: search by telegram_id
if not panel_user and user.telegram_id:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if panel_users:
panel_user = panel_users[0]
# Fallback: search by email (OAuth users)
if not panel_user and user.email:
panel_users_by_email = await api.get_user_by_email(user.email)
if panel_users_by_email:
panel_user = panel_users_by_email[0]
if panel_user:
panel_found = True
panel_status = panel_user.status.value if panel_user.status else None
panel_expire_at = panel_user.expire_at
@@ -1884,27 +2253,30 @@ async def sync_user_from_panel(
errors = []
panel_info = None
# Email-only users cannot be synced from panel by telegram_id
if not user.telegram_id:
return SyncFromPanelResponse(
success=False,
message='Cannot sync email-only user',
errors=["Email-only users don't have telegram_id for panel lookup"],
)
async with service.get_api_client() as api:
# Find user in panel
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
# Find user in panel: UUID → telegram_id → email
panel_user = None
if not panel_users:
if user.remnawave_uuid:
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not panel_user and user.telegram_id:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if panel_users:
panel_user = panel_users[0]
if not panel_user and user.email:
panel_users_by_email = await api.get_user_by_email(user.email)
if panel_users_by_email:
panel_user = panel_users_by_email[0]
if not panel_user:
return SyncFromPanelResponse(
success=False,
message='User not found in panel',
errors=['No user with this telegram_id found in Remnawave panel'],
errors=['No user found in Remnawave panel by UUID, telegram_id, or email'],
)
panel_user = panel_users[0]
# Build panel info
active_squads = []
if hasattr(panel_user, 'active_internal_squads') and panel_user.active_internal_squads:
@@ -2113,19 +2485,31 @@ async def sync_user_to_panel(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
description = settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
async with service.get_api_client() as api:
# Try to find existing user in panel
# Validate existing UUID
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
panel_uuid = None
user.remnawave_uuid = None
# Fallback: search by telegram_id
if not panel_uuid and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
@@ -2133,6 +2517,14 @@ async def sync_user_to_panel(
user.remnawave_uuid = panel_uuid
changes['remnawave_uuid_discovered'] = panel_uuid
# Fallback: search by email (OAuth users)
if not panel_uuid and user.email:
existing_users = await api.get_user_by_email(user.email)
if existing_users:
panel_uuid = existing_users[0].uuid
user.remnawave_uuid = panel_uuid
changes['remnawave_uuid_discovered'] = panel_uuid
if panel_uuid:
# Update existing user
update_kwargs = {'uuid': panel_uuid}
@@ -2178,6 +2570,7 @@ async def sync_user_to_panel(
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
'telegram_id': user.telegram_id,
'email': user.email,
'description': description,
'active_internal_squads': sub.connected_squads or [],
}
+13
View File
@@ -13,6 +13,7 @@ class UserTrafficItem(BaseModel):
user_id: int
telegram_id: int | None
username: str | None
email: str | None
full_name: str
tariff_name: str | None
subscription_status: str | None
@@ -33,6 +34,18 @@ class TrafficUsageResponse(BaseModel):
available_statuses: list[str]
class UserTrafficEnrichment(BaseModel):
devices_connected: int = 0
total_spent_kopeks: int = 0
subscription_start_date: str | None = None
subscription_end_date: str | None = None
last_node_name: str | None = None
class TrafficEnrichmentResponse(BaseModel):
data: dict[int, UserTrafficEnrichment]
class ExportCsvRequest(BaseModel):
period: int = Field(30, ge=1, le=30)
start_date: str | None = None
+78
View File
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
# === User Subscription Info ===
class TrafficPurchaseItem(BaseModel):
"""Individual traffic purchase record."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
is_expired: bool
class UserSubscriptionInfo(BaseModel):
"""User subscription information."""
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
autopay_enabled: bool = False
is_active: bool = False
days_remaining: int = 0
purchased_traffic_gb: int = 0
traffic_purchases: list[TrafficPurchaseItem] = []
class UserPromoGroupInfo(BaseModel):
@@ -285,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
# For toggle_autopay
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
# For add_traffic action
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
# For remove_traffic action
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
# For create new subscription
is_trial: bool | None = Field(None, description='Is trial subscription')
device_limit: int | None = Field(None, ge=1, description='Device limit')
@@ -348,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
message: str
class UpdateReferralCommissionRequest(BaseModel):
"""Request to update user referral commission percent."""
commission_percent: int | None = Field(
None, ge=0, le=100, description='Referral commission percent (null for default)'
)
class UpdateReferralCommissionResponse(BaseModel):
"""Response after referral commission update."""
success: bool
old_commission_percent: int | None = None
new_commission_percent: int | None = None
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
hwid: str
platform: str = ''
device_model: str = ''
created_at: str | None = None
class UserDevicesResponse(BaseModel):
"""User devices from panel."""
devices: list[DeviceInfo] = []
total: int = 0
device_limit: int = 0
class DeleteDeviceResponse(BaseModel):
"""Response after device deletion."""
success: bool
message: str
deleted_hwid: str | None = None
class ResetDevicesResponse(BaseModel):
"""Response after resetting all devices."""
success: bool
message: str
deleted_count: int = 0
class DeleteUserRequest(BaseModel):
"""Request to delete user."""
@@ -441,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
min_days: int = 1
max_days: int = 365
# Device limits
device_price_kopeks: int | None = None
max_device_limit: int | None = None
# Traffic topup
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = {}
max_topup_traffic_gb: int = 0
# Access info
is_available: bool = True # Available for this user's promo group
requires_promo_group: bool = False # Requires specific promo group
+24
View File
@@ -999,6 +999,30 @@ class RemnaWaveAPI:
uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config')
)
async def get_all_hwid_devices(self) -> dict[str, Any]:
"""GET /api/hwid/devices — all devices for all users (paginated, max 1000/page)."""
all_devices: list[dict[str, Any]] = []
start = 0
page_size = 1000
while True:
response = await self._make_request('GET', '/api/hwid/devices', params={'start': start, 'size': page_size})
data = response.get('response', {'devices': [], 'total': 0})
devices = data.get('devices', [])
total = data.get('total', 0)
all_devices.extend(devices)
if len(all_devices) >= total or not devices:
break
start += len(devices)
return {'devices': all_devices, 'total': len(all_devices)}
async def get_all_panel_subscriptions(self) -> list[dict[str, Any]]:
"""GET /api/subscriptions — all panel subscriptions."""
response = await self._make_request('GET', '/api/subscriptions')
return response.get('response') or []
async def get_user_devices(self, user_uuid: str) -> dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
+6
View File
@@ -4621,6 +4621,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
user_id=target_user.id,
),
active_internal_squads=subscription.connected_squads,
)
@@ -4634,6 +4636,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
user_id=target_user.id,
)
async with remnawave_service.get_api_client() as api:
create_kwargs = dict(
@@ -4645,10 +4649,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
email=target_user.email,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
),
active_internal_squads=subscription.connected_squads,
)
+35 -10
View File
@@ -1424,10 +1424,23 @@ async def confirm_daily_tariff_purchase(
# ==================== Продление по тарифу ====================
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
if additional <= 0:
return 0
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
if device_price <= 0:
return 0
months = max(1, round(period_days / 30))
return additional * device_price * months
def get_tariff_extend_keyboard(
tariff: Tariff,
language: str,
db_user: User | None = None,
subscription_device_limit: int | None = None,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
texts = get_texts(language)
@@ -1438,6 +1451,10 @@ def get_tariff_extend_keyboard(
period = int(period_str)
price = prices[period_str]
# Добавляем стоимость дополнительных устройств
if subscription_device_limit is not None:
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
# Получаем скидку для конкретного периода
discount_percent = 0
if db_user:
@@ -1508,13 +1525,17 @@ async def show_tariff_extend(
if has_period_discounts:
discount_hint = '\n🎁 <i>Скидки зависят от выбранного периода</i>'
actual_device_limit = subscription.device_limit or tariff.device_limit
await callback.message.edit_text(
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n\n'
f'📱 Устройств: {actual_device_limit}\n\n'
'Выберите период продления:',
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
reply_markup=get_tariff_extend_keyboard(
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
),
parse_mode='HTML',
)
await callback.answer()
@@ -1538,12 +1559,16 @@ async def select_tariff_extend_period(
await callback.answer('Тариф недоступен', show_alert=True)
return
subscription = await get_subscription_by_user_id(db, db_user.id)
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
# Получаем скидку для выбранного периода
discount_percent = _get_user_period_discount(db_user, period)
# Получаем цену
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, discount_percent)
# Проверяем баланс
@@ -1560,7 +1585,7 @@ async def select_tariff_extend_period(
f'✅ <b>Подтверждение продления</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Период: {_format_period(period)}\n'
f'{discount_text}\n'
f'💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n'
@@ -1572,9 +1597,6 @@ async def select_tariff_extend_period(
else:
missing = final_price - user_balance
# Получаем текущую подписку для сохранения в корзину
subscription = await get_subscription_by_user_id(db, db_user.id)
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'extend',
@@ -1588,7 +1610,7 @@ async def select_tariff_extend_period(
'return_to_cart': True,
'description': f'Продление тарифа {tariff.name} на {period} дней',
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'device_limit': actual_device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
@@ -1641,12 +1663,15 @@ async def confirm_tariff_extend(
await callback.answer('Подписка не найдена', show_alert=True)
return
actual_device_limit = subscription.device_limit or tariff.device_limit
data = await state.get_data()
discount_percent = data.get('extend_discount_percent', 0)
# Получаем цену
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, discount_percent)
# Проверяем баланс
@@ -1724,7 +1749,7 @@ async def confirm_tariff_extend(
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Добавлено: {_format_period(period)}\n'
f'💰 Списано: {_format_price_kopeks(final_price)}',
reply_markup=InlineKeyboardMarkup(
+11
View File
@@ -1861,6 +1861,8 @@ class RemnaWaveService:
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id,
email=user.email,
user_id=user.id,
)
create_kwargs = dict(
@@ -1895,6 +1897,15 @@ class RemnaWaveService:
panel_uuid = existing_users[0].uuid
logger.debug(f'Найден пользователь {user.telegram_id} в панели: {panel_uuid}')
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
if not panel_uuid and user.email:
existing_users = await api.get_user_by_email(user.email)
if existing_users:
panel_uuid = existing_users[0].uuid
logger.debug(
f'Найден пользователь {user.email} в панели по email: {panel_uuid}'
)
if panel_uuid:
update_kwargs = dict(
uuid=panel_uuid,
+14 -6
View File
@@ -5,7 +5,6 @@
"""
import logging
import os
from datetime import datetime
from typing import Final
@@ -24,7 +23,6 @@ from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
# Константы
VERSION_ENV_VAR: Final[str] = 'VERSION'
DEFAULT_VERSION: Final[str] = 'dev'
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
@@ -70,10 +68,20 @@ class StartupNotificationService:
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
def _get_version(self) -> str:
"""Получает версию из переменной окружения VERSION."""
version = os.getenv(VERSION_ENV_VAR, '').strip()
if version:
return version
"""Получает версию из pyproject.toml."""
try:
from pathlib import Path
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
if pyproject_path.exists():
for line in pyproject_path.read_text().splitlines():
if line.strip().startswith('version'):
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
if ver:
return ver
except Exception:
pass
return DEFAULT_VERSION
async def _get_users_count(self) -> int:
+12 -5
View File
@@ -205,17 +205,24 @@ class SubscriptionService:
# Ищем существующего пользователя в панели
existing_users = []
if user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
elif user.remnawave_uuid:
# Для email-пользователей ищем по uuid если есть
if user.remnawave_uuid:
try:
existing_user = await api.get_user(user.remnawave_uuid)
existing_user = await api.get_user_by_uuid(user.remnawave_uuid)
if existing_user:
existing_users = [existing_user]
except Exception:
pass
if not existing_users and user.telegram_id:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
if not existing_users and user.email:
try:
existing_users = await api.get_user_by_email(user.email)
except Exception:
pass
if existing_users:
logger.info(f'🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}')
remnawave_user = existing_users[0]
+11 -9
View File
@@ -82,16 +82,18 @@ class VersionService:
return 'UNKNOW'
def _get_current_version(self) -> str:
import os
try:
from pathlib import Path
current = os.getenv('VERSION', '').strip()
if current:
if '-' in current and current.startswith('v'):
base_version = current.split('-')[0]
if base_version.count('.') == 2:
return base_version
return current
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
if pyproject_path.exists():
for line in pyproject_path.read_text().splitlines():
if line.strip().startswith('version'):
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
if ver:
return ver
except Exception:
pass
return 'UNKNOW'
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.7.2"
version = "3.8.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }