Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr

Enhance miniapp subscription details
This commit is contained in:
Egor
2025-10-01 03:46:09 +03:00
committed by GitHub
3 changed files with 374 additions and 11 deletions

View File

@@ -1,15 +1,20 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import Subscription, Transaction
from app.database.models import Subscription, Transaction, User
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
from app.utils.telegram_webapp import (
@@ -19,6 +24,9 @@ from app.utils.telegram_webapp import (
from ..dependencies import get_db_session
from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
MiniAppPromoGroup,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
MiniAppSubscriptionUser,
@@ -71,6 +79,122 @@ def _status_label(status: str) -> str:
return mapping.get(status, status.title())
def _parse_datetime_string(value: Optional[str]) -> Optional[str]:
if not value:
return None
try:
cleaned = value.strip()
if cleaned.endswith("Z"):
cleaned = f"{cleaned[:-1]}+00:00"
# Normalize duplicated timezone suffixes like +00:00+00:00
if "+00:00+00:00" in cleaned:
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
datetime.fromisoformat(cleaned)
return cleaned
except Exception: # pragma: no cover - defensive
return value
async def _resolve_connected_servers(
db: AsyncSession,
squad_uuids: List[str],
) -> List[MiniAppConnectedServer]:
if not squad_uuids:
return []
resolved: Dict[str, str] = {}
missing: List[str] = []
for squad_uuid in squad_uuids:
if squad_uuid in resolved:
continue
server = await get_server_squad_by_uuid(db, squad_uuid)
if server and server.display_name:
resolved[squad_uuid] = server.display_name
else:
missing.append(squad_uuid)
if missing:
try:
service = RemnaWaveService()
if service.is_configured:
squads = await service.get_all_squads()
for squad in squads:
uuid = squad.get("uuid")
name = squad.get("name")
if uuid in missing and name:
resolved[uuid] = name
except RemnaWaveConfigurationError:
logger.debug("RemnaWave is not configured; skipping server name enrichment")
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
connected_servers: List[MiniAppConnectedServer] = []
for squad_uuid in squad_uuids:
name = resolved.get(squad_uuid, squad_uuid)
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
return connected_servers
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
remnawave_uuid = getattr(user, "remnawave_uuid", None)
if not remnawave_uuid:
return 0, []
try:
service = RemnaWaveService()
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to initialise RemnaWave service: %s", error)
return 0, []
if not service.is_configured:
return 0, []
try:
async with service.get_api_client() as api:
response = await api.get_user_devices(remnawave_uuid)
except RemnaWaveConfigurationError:
logger.debug("RemnaWave configuration missing while loading devices")
return 0, []
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to load devices from RemnaWave: %s", error)
return 0, []
total_devices = int(response.get("total") or 0)
devices_payload = response.get("devices") or []
devices: List[MiniAppDevice] = []
for device in devices_payload:
platform = device.get("platform") or device.get("platformType")
model = device.get("deviceModel") or device.get("model") or device.get("name")
app_version = device.get("appVersion") or device.get("version")
last_seen_raw = (
device.get("updatedAt")
or device.get("lastSeen")
or device.get("lastActiveAt")
or device.get("createdAt")
)
last_ip = device.get("ip") or device.get("ipAddress")
devices.append(
MiniAppDevice(
platform=platform,
device_model=model,
app_version=app_version,
last_seen=_parse_datetime_string(last_seen_raw),
last_ip=last_ip,
)
)
if total_devices == 0:
total_devices = len(devices)
return total_devices, devices
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
username = user_data.get("username")
if username:
@@ -186,6 +310,8 @@ async def get_subscription_details(
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
connected_squads: List[str] = list(subscription.connected_squads or [])
connected_servers = await _resolve_connected_servers(db, connected_squads)
devices_count, devices = await _load_devices_info(user)
links: List[str] = links_payload.get("links") or connected_squads
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
@@ -202,6 +328,8 @@ async def get_subscription_details(
if isinstance(balance_currency, str):
balance_currency = balance_currency.upper()
promo_group = getattr(user, "promo_group", None)
response_user = MiniAppSubscriptionUser(
telegram_id=user.telegram_id,
username=user.username,
@@ -239,6 +367,9 @@ async def get_subscription_details(
links=links,
ss_conf_links=ss_conf_links,
connected_squads=connected_squads,
connected_servers=connected_servers,
connected_devices_count=devices_count,
connected_devices=devices,
happ=links_payload.get("happ"),
happ_link=links_payload.get("happ_link"),
happ_crypto_link=links_payload.get("happ_crypto_link"),
@@ -247,5 +378,10 @@ async def get_subscription_details(
balance_rubles=round(user.balance_rubles, 2),
balance_currency=balance_currency,
transactions=[_serialize_transaction(tx) for tx in transactions],
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
if promo_group
else None,
subscription_type="trial" if subscription.is_trial else "paid",
autopay_enabled=bool(subscription.autopay_enabled),
)

View File

@@ -31,6 +31,24 @@ class MiniAppSubscriptionUser(BaseModel):
has_active_subscription: bool = False
class MiniAppPromoGroup(BaseModel):
id: int
name: str
class MiniAppConnectedServer(BaseModel):
uuid: str
name: str
class MiniAppDevice(BaseModel):
platform: Optional[str] = None
device_model: Optional[str] = None
app_version: Optional[str] = None
last_seen: Optional[str] = None
last_ip: Optional[str] = None
class MiniAppTransaction(BaseModel):
id: int
type: str
@@ -54,6 +72,9 @@ class MiniAppSubscriptionResponse(BaseModel):
links: List[str] = Field(default_factory=list)
ss_conf_links: Dict[str, str] = Field(default_factory=dict)
connected_squads: List[str] = Field(default_factory=list)
connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list)
connected_devices_count: int = 0
connected_devices: List[MiniAppDevice] = Field(default_factory=list)
happ: Optional[Dict[str, Any]] = None
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
@@ -62,4 +83,7 @@ class MiniAppSubscriptionResponse(BaseModel):
balance_rubles: float = 0.0
balance_currency: Optional[str] = None
transactions: List[MiniAppTransaction] = Field(default_factory=list)
promo_group: Optional[MiniAppPromoGroup] = None
subscription_type: str
autopay_enabled: bool = False

View File

@@ -230,6 +230,39 @@
margin-bottom: 0;
}
.device-list {
list-style: none;
padding: 0;
margin: 0;
}
.device-item {
padding: 12px;
background: white;
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--border-color);
margin-bottom: 8px;
}
.device-item:last-child {
margin-bottom: 0;
}
.device-title {
font-weight: 600;
margin-bottom: 4px;
}
.device-meta {
font-size: 12px;
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* User Info */
.user-header {
display: flex;
@@ -302,7 +335,7 @@
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
@@ -606,6 +639,10 @@
<div class="stat-value" id="serversCount">-</div>
<div class="stat-label" data-i18n="stats.servers">Servers</div>
</div>
<div class="stat-item">
<div class="stat-value" id="devicesCount">-</div>
<div class="stat-label" data-i18n="stats.devices">Devices</div>
</div>
</div>
<div class="info-item">
@@ -620,6 +657,22 @@
<span class="info-label" data-i18n="info.traffic_limit">Traffic Limit</span>
<span class="info-value" id="trafficLimit">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.subscription_type">Subscription Type</span>
<span class="info-value" id="subscriptionType">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.promo_group">Promo Group</span>
<span class="info-value" id="promoGroup">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
<span class="info-value" id="deviceLimit">-</span>
</div>
<div class="info-item">
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
<span class="info-value" id="autopayStatus">-</span>
</div>
</div>
<!-- Action Buttons -->
@@ -660,6 +713,12 @@
<div class="empty-state hidden" id="serversEmpty" data-i18n="servers.empty">No servers connected yet</div>
</div>
<div class="card" id="devicesCard">
<div class="card-title" data-i18n="card.devices.title">Connected Devices</div>
<ul class="device-list" id="devicesList"></ul>
<div class="empty-state hidden" id="devicesEmpty" data-i18n="devices.empty">No devices connected yet</div>
</div>
<!-- App Installation Section -->
<div class="app-section">
<div class="card">
@@ -716,15 +775,21 @@
'error.default.message': 'Please contact support to activate your subscription.',
'stats.days_left': 'Days left',
'stats.servers': 'Servers',
'stats.devices': 'Devices',
'info.expires': 'Expires',
'info.traffic_used': 'Traffic used',
'info.traffic_limit': 'Traffic limit',
'info.subscription_type': 'Subscription type',
'info.promo_group': 'Promo group',
'info.device_limit': 'Device limit',
'info.autopay': 'Auto-pay',
'button.connect.default': 'Connect to VPN',
'button.connect.happ': 'Connect',
'button.copy': 'Copy subscription link',
'card.balance.title': 'Balance',
'card.history.title': 'Transaction History',
'card.servers.title': 'Connected Servers',
'card.devices.title': 'Connected Devices',
'apps.title': 'Installation guide',
'apps.no_data': 'No installation guide available for this platform yet.',
'apps.featured': 'Recommended',
@@ -740,6 +805,7 @@
'history.type.refund': 'Refund',
'history.type.referral_reward': 'Referral reward',
'servers.empty': 'No servers connected yet',
'devices.empty': 'No devices connected yet',
'language.ariaLabel': 'Select interface language',
'notifications.copy.success': 'Subscription link copied to clipboard.',
'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.',
@@ -750,12 +816,17 @@
'status.expired': 'Expired',
'status.disabled': 'Disabled',
'status.unknown': 'Unknown',
'subscription.type.trial': 'Trial',
'subscription.type.paid': 'Paid',
'autopay.enabled': 'Enabled',
'autopay.disabled': 'Disabled',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'PC',
'platform.tv': 'TV',
'units.gb': 'GB',
'values.unlimited': 'Unlimited'
'values.unlimited': 'Unlimited',
'values.not_available': 'Not available'
},
ru: {
'app.title': 'Подписка VPN',
@@ -766,15 +837,21 @@
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
'stats.days_left': 'Осталось дней',
'stats.servers': 'Серверы',
'stats.devices': 'Устройства',
'info.expires': 'Действует до',
'info.traffic_used': 'Использовано трафика',
'info.traffic_limit': 'Лимит трафика',
'info.subscription_type': 'Тип подписки',
'info.promo_group': 'Промогруппа',
'info.device_limit': 'Лимит устройств',
'info.autopay': 'Автоплатеж',
'button.connect.default': 'Подключиться к VPN',
'button.connect.happ': 'Подключиться',
'button.copy': 'Скопировать ссылку подписки',
'card.balance.title': 'Баланс',
'card.history.title': 'История операций',
'card.servers.title': 'Подключённые серверы',
'card.devices.title': 'Подключенные устройства',
'apps.title': 'Инструкция по установке',
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
'apps.featured': 'Рекомендуем',
@@ -790,6 +867,7 @@
'history.type.refund': 'Возврат',
'history.type.referral_reward': 'Реферальное вознаграждение',
'servers.empty': 'Подключённых серверов пока нет',
'devices.empty': 'Подключённых устройств пока нет',
'language.ariaLabel': 'Выберите язык интерфейса',
'notifications.copy.success': 'Ссылка подписки скопирована.',
'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.',
@@ -800,12 +878,17 @@
'status.expired': 'Истекла',
'status.disabled': 'Отключена',
'status.unknown': 'Неизвестно',
'subscription.type.trial': 'Триал',
'subscription.type.paid': 'Платная',
'autopay.enabled': 'Включен',
'autopay.disabled': 'Выключен',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'ПК',
'platform.tv': 'TV',
'platform.tv': 'ТВ',
'units.gb': 'ГБ',
'values.unlimited': 'Безлимит'
'values.unlimited': 'Безлимит',
'values.not_available': 'Недоступно'
}
};
@@ -1087,19 +1170,74 @@
const serversCount = Array.isArray(userData.connected_squads)
? userData.connected_squads.length
: Array.isArray(userData.links)
? userData.links.length
: 0;
: Array.isArray(userData.connected_servers)
? userData.connected_servers.length
: Array.isArray(userData.links)
? userData.links.length
: 0;
document.getElementById('serversCount').textContent = serversCount;
const devicesCountRaw = Number(userData?.connected_devices_count);
const devicesCount = Number.isFinite(devicesCountRaw)
? devicesCountRaw
: Array.isArray(userData?.connected_devices)
? userData.connected_devices.length
: 0;
const devicesCountElement = document.getElementById('devicesCount');
if (devicesCountElement) {
devicesCountElement.textContent = devicesCount;
}
document.getElementById('trafficUsed').textContent =
user.traffic_used_label || formatTraffic(user.traffic_used_gb);
document.getElementById('trafficLimit').textContent =
user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb);
const deviceLimitElement = document.getElementById('deviceLimit');
if (deviceLimitElement) {
const limitValue = typeof user.device_limit === 'number'
? user.device_limit
: Number.parseInt(user.device_limit ?? '', 10);
deviceLimitElement.textContent = Number.isFinite(limitValue)
? String(limitValue)
: t('values.not_available');
}
const subscriptionTypeElement = document.getElementById('subscriptionType');
if (subscriptionTypeElement) {
const fallbackSubscriptionType = (user?.subscription_status || '').toLowerCase() === 'trial'
? 'trial'
: 'paid';
const subscriptionTypeRaw = String(
userData?.subscription_type
|| fallbackSubscriptionType
).toLowerCase();
const subscriptionTypeKey = `subscription.type.${subscriptionTypeRaw}`;
const subscriptionTypeLabel = t(subscriptionTypeKey);
subscriptionTypeElement.textContent = subscriptionTypeLabel === subscriptionTypeKey
? subscriptionTypeRaw
: subscriptionTypeLabel;
}
const promoGroupElement = document.getElementById('promoGroup');
if (promoGroupElement) {
const promoGroupName = userData?.promo_group?.name || user?.promo_group?.name;
promoGroupElement.textContent = promoGroupName || t('values.not_available');
}
const autopayElement = document.getElementById('autopayStatus');
if (autopayElement) {
const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled';
const autopayLabel = t(autopayKey);
autopayElement.textContent = autopayLabel === autopayKey
? (userData?.autopay_enabled ? 'On' : 'Off')
: autopayLabel;
}
renderBalanceSection();
renderTransactionHistory();
renderServersList();
renderDevicesList();
updateConnectButtonLabel();
updateActionButtons();
}
@@ -1432,7 +1570,16 @@
return;
}
const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : [];
let servers = [];
if (Array.isArray(userData?.connected_servers) && userData.connected_servers.length) {
servers = userData.connected_servers.map(server => ({
uuid: server?.uuid || '',
name: server?.name || server?.uuid || '',
}));
} else if (Array.isArray(userData?.connected_squads)) {
servers = userData.connected_squads.map(uuid => ({ uuid, name: uuid }));
}
if (!servers.length) {
list.innerHTML = '';
emptyState.textContent = t('servers.empty');
@@ -1441,7 +1588,63 @@
}
emptyState.classList.add('hidden');
list.innerHTML = servers.map(server => `<li class="server-item">${escapeHtml(server)}</li>`).join('');
list.innerHTML = servers
.map(server => `<li class="server-item">${escapeHtml(server.name || server.uuid || '')}</li>`)
.join('');
}
function renderDevicesList() {
const list = document.getElementById('devicesList');
const emptyState = document.getElementById('devicesEmpty');
if (!list || !emptyState) {
return;
}
const devices = Array.isArray(userData?.connected_devices)
? userData.connected_devices
: [];
if (!devices.length) {
list.innerHTML = '';
emptyState.textContent = t('devices.empty');
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
list.innerHTML = devices.map(device => {
const platform = device?.platform ? String(device.platform) : '';
const model = device?.device_model ? String(device.device_model) : '';
const titleParts = [platform, model].filter(Boolean);
const title = titleParts.length
? titleParts.join(' — ')
: t('values.not_available');
const metaParts = [];
if (device?.app_version) {
metaParts.push(String(device.app_version));
}
if (device?.last_seen) {
const formatted = formatDateTime(device.last_seen);
if (formatted) {
metaParts.push(formatted);
}
}
if (device?.last_ip) {
metaParts.push(String(device.last_ip));
}
const metaHtml = metaParts.length
? `<div class="device-meta">${metaParts.map(part => `<span>${escapeHtml(part)}</span>`).join('')}</div>`
: '';
return `
<li class="device-item">
<div class="device-title">${escapeHtml(title)}</div>
${metaHtml}
</li>
`;
}).join('');
}
function getCurrentSubscriptionUrl() {