diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py
index b7319740..dc57dd75 100644
--- a/app/handlers/subscription/autopay.py
+++ b/app/handlers/subscription/autopay.py
@@ -231,6 +231,16 @@ async def handle_subscription_config_back(
)
await state.set_state(SubscriptionStates.selecting_period)
+ elif current_state == SubscriptionStates.confirming_purchase.state:
+ data = await state.get_data()
+ selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
+
+ await callback.message.edit_text(
+ texts.SELECT_DEVICES,
+ reply_markup=get_devices_keyboard(selected_devices, db_user.language)
+ )
+ await state.set_state(SubscriptionStates.selecting_devices)
+
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 58bad0b7..99bf3e7a 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
+from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,14 +81,64 @@ from app.utils.promo_offer import (
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
-from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
-from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
-from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
-from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
+from .autopay import (
+ handle_autopay_menu,
+ handle_subscription_cancel,
+ handle_subscription_config_back,
+ set_autopay_days,
+ show_autopay_days,
+ toggle_autopay,
+)
+from .countries import (
+ _get_available_countries,
+ _should_show_countries_management,
+ apply_countries_changes,
+ countries_continue,
+ handle_add_countries,
+ handle_manage_country,
+ select_country,
+)
+from .devices import (
+ confirm_add_devices,
+ confirm_change_devices,
+ confirm_reset_devices,
+ execute_change_devices,
+ get_current_devices_count,
+ get_servers_display_names,
+ handle_all_devices_reset_from_management,
+ handle_app_selection,
+ handle_change_devices,
+ handle_device_guide,
+ handle_device_management,
+ handle_devices_page,
+ handle_reset_devices,
+ handle_single_device_reset,
+ handle_specific_app_guide,
+ show_device_connection_help,
+)
+from .happ import (
+ handle_happ_download_back,
+ handle_happ_download_close,
+ handle_happ_download_platform_choice,
+ handle_happ_download_request,
+)
from .links import handle_connect_subscription, handle_open_subscription_link
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
-from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
-from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
+from .promo import (
+ _build_promo_group_discount_text,
+ _get_promo_offer_hint,
+ claim_discount_offer,
+ handle_promo_offer_close,
+)
+from .traffic import (
+ confirm_reset_traffic,
+ confirm_switch_traffic,
+ execute_switch_traffic,
+ handle_no_traffic_packages,
+ handle_reset_traffic,
+ handle_switch_traffic,
+ select_traffic,
+)
async def show_subscription_info(
callback: types.CallbackQuery,
@@ -528,11 +579,12 @@ async def start_subscription_purchase(
texts = get_texts(db_user.language)
keyboard = get_subscription_period_keyboard(db_user.language)
+ prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
- await callback.message.edit_text(
- await _build_subscription_period_prompt(db_user, texts, db),
- reply_markup=keyboard,
- parse_mode="HTML",
+ await _edit_message_text_or_caption(
+ callback.message,
+ prompt_text,
+ keyboard,
)
subscription = getattr(db_user, 'subscription', None)
@@ -557,6 +609,46 @@ async def start_subscription_purchase(
await state.set_state(SubscriptionStates.selecting_period)
await callback.answer()
+
+async def _edit_message_text_or_caption(
+ message: types.Message,
+ text: str,
+ reply_markup: InlineKeyboardMarkup,
+ parse_mode: Optional[str] = "HTML",
+) -> None:
+ """Edits message text when possible, falls back to caption or re-sends message."""
+
+ try:
+ await message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode,
+ )
+ except TelegramBadRequest as error:
+ error_message = str(error).lower()
+
+ if "message is not modified" in error_message:
+ return
+
+ if "there is no text in the message to edit" in error_message:
+ if message.caption is not None:
+ await message.edit_caption(
+ caption=text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode,
+ )
+ return
+
+ await message.delete()
+ await message.answer(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode,
+ )
+ return
+
+ raise
+
async def save_cart_and_redirect_to_topup(
callback: types.CallbackQuery,
state: FSMContext,
@@ -2310,6 +2402,11 @@ async def handle_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
price_kopeks = _calculate_simple_subscription_price(subscription_params)
+ traffic_text = (
+ "Безлимит"
+ if subscription_params["traffic_limit_gb"] == 0
+ else f"{subscription_params['traffic_limit_gb']} ГБ"
+ )
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
@@ -2317,7 +2414,7 @@ async def handle_simple_subscription_purchase(
f"⚡ Простая покупка подписки\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
- f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
+ f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
@@ -2335,7 +2432,7 @@ async def handle_simple_subscription_purchase(
f"⚡ Простая покупка подписки\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
- f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
+ f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 7d956998..841d50fe 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -1370,7 +1370,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
- text=f"{days} дн{_get_days_suffix(days)}",
+ text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1382,13 +1382,12 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
-def _get_days_suffix(days: int) -> str:
- if days == 1:
- return "ь"
- elif 2 <= days <= 4:
- return "я"
- else:
- return "ей"
+def _get_days_word(days: int) -> str:
+ if days % 10 == 1 and days % 100 != 11:
+ return "день"
+ if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
+ return "дня"
+ return "дней"
diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py
index b0cc9505..af0697be 100644
--- a/app/services/reporting_service.py
+++ b/app/services/reporting_service.py
@@ -3,13 +3,16 @@ import logging
from dataclasses import dataclass
from datetime import date, datetime, time as datetime_time, timedelta, timezone
from enum import Enum
-from typing import Optional, Tuple
+from html import escape
+from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
-from sqlalchemy import func, select
+from sqlalchemy import cast, func, not_, or_, select
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.sql import false, true
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
@@ -17,10 +20,12 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
+ SubscriptionStatus,
Ticket,
TicketStatus,
Transaction,
TransactionType,
+ User,
)
@@ -45,7 +50,7 @@ class ReportPeriodRange:
class ReportingService:
- """Generates admin summary reports and can schedule daily delivery."""
+ """Generates admin summary reports (text only, no charts)."""
def __init__(self) -> None:
self.bot: Optional[Bot] = None
@@ -170,11 +175,64 @@ class ReportingService:
chat_id=chat_id,
text=report_text,
message_thread_id=topic_id,
+ parse_mode="HTML",
)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.error("Не удалось отправить отчет: %s", exc)
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
+ # ---------- referral helpers ----------
+
+ def _referral_markers(self) -> List:
+ """
+ Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT).
+ """
+ clauses = []
+
+ # Явные флаги
+ if hasattr(Transaction, "is_referral_bonus"):
+ clauses.append(Transaction.is_referral_bonus == true())
+ if hasattr(Transaction, "is_bonus"):
+ clauses.append(Transaction.is_bonus == true())
+
+ # Источник/причина
+ if hasattr(Transaction, "source"):
+ clauses.append(Transaction.source == "referral")
+ clauses.append(Transaction.source == "referral_bonus")
+ if hasattr(Transaction, "reason"):
+ clauses.append(Transaction.reason == "referral")
+ clauses.append(Transaction.reason == "referral_bonus")
+ clauses.append(Transaction.reason == "referral_reward")
+
+ # Текстовые поля
+ like_patterns = ["%реферал%", "%реферальн%", "%referral%"]
+ if hasattr(Transaction, "description"):
+ for pattern in like_patterns:
+ try:
+ clauses.append(Transaction.description.ilike(pattern))
+ except Exception: # noqa: BLE001 - best effort
+ pass
+ if hasattr(Transaction, "comment"):
+ for pattern in like_patterns:
+ try:
+ clauses.append(Transaction.comment.ilike(pattern))
+ except Exception: # noqa: BLE001 - best effort
+ pass
+
+ return [clause for clause in clauses if clause is not None]
+
+ def _exclude_referral_deposits_condition(self):
+ """
+ Условие «это НЕ реферальный бонус».
+ Если нет ни одного маркера — ничего не исключаем.
+ """
+ markers = self._referral_markers()
+ if not markers:
+ return true()
+ return not_(or_(*markers))
+
+ # --------------------------------------
+
async def _build_report(
self,
period: ReportPeriod,
@@ -186,38 +244,91 @@ class ReportingService:
async with AsyncSessionLocal() as session:
totals = await self._collect_current_totals(session)
- period_stats = await self._collect_period_stats(session, start_utc, end_utc)
+ stats = await self._collect_period_stats(session, start_utc, end_utc)
+ top_referrers = await self._get_top_referrers(session, start_utc, end_utc, limit=5)
+ usage = await self._get_user_usage_stats(session)
+ conversion_rate = (
+ (stats["trial_to_paid_conversions"] / stats["new_trials"] * 100)
+ if stats["new_trials"] > 0
+ else 0.0
+ )
+
+ lines: List[str] = []
header = (
f"📊 Отчет за {period_range.label}"
if period == ReportPeriod.DAILY
else f"📊 Отчет за период {period_range.label}"
)
+ lines += [header, ""]
- lines = [
- header,
+ # TL;DR
+ lines += [
+ "🧭 Итог по периоду",
+ f"• Новых пользователей: {stats['new_users']}",
+ f"• Новых триалов: {stats['new_trials']}",
+ (
+ f"• Конверсий триал → платная: {stats['trial_to_paid_conversions']} "
+ f"({conversion_rate:.1f}%)"
+ ),
+ f"• Новых платных (всего): {stats['new_paid_subscriptions']}",
+ f"• Поступления всего (только пополнения): {self._format_amount(stats['deposits_amount'])}",
"",
- "🎯 Триалы",
- f"• Активных сейчас: {totals['active_trials']}",
- f"• Новых за период: {period_stats['new_trials']}",
- "",
- "💎 Платные подписки",
- f"• Активных сейчас: {totals['active_paid']}",
- f"• Новых за период: {period_stats['new_paid_subscriptions']}",
- "",
- "🎟️ Тикеты поддержки",
- f"• Активных сейчас: {totals['open_tickets']}",
- f"• Новых за период: {period_stats['new_tickets']}",
- "",
- "💰 Платежи",
- f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
- f"{self._format_amount(period_stats['subscription_payments_amount'])}",
- f"• Пополнений: {period_stats['deposits_count']} на сумму "
- f"{self._format_amount(period_stats['deposits_amount'])}",
- f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
- f"{self._format_amount(period_stats['total_payments_amount'])}",
]
+ # Подписки
+ lines += [
+ "💎 Подписки",
+ f"• Активные триалы сейчас: {totals['active_trials']}",
+ f"• Активные платные сейчас: {totals['active_paid']}",
+ "",
+ ]
+
+ # Финансы
+ lines += [
+ "💰 Финансы",
+ (
+ "• Оплаты подписок: "
+ f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}"
+ ),
+ (
+ "• Пополнения: "
+ f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}"
+ ),
+ (
+ "Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы "
+ "исключены."
+ ),
+ "",
+ ]
+
+ # Поддержка
+ lines += [
+ "🎟️ Поддержка",
+ f"• Новых тикетов: {stats['new_tickets']}",
+ f"• Активных тикетов сейчас: {totals['open_tickets']}",
+ "",
+ ]
+
+ # Активность пользователей
+ lines += [
+ "👤 Активность пользователей",
+ f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}",
+ f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}",
+ "",
+ ]
+
+ # Топ по рефералам
+ lines += ["🤝 Топ по рефералам (за период)"]
+ if top_referrers:
+ for index, row in enumerate(top_referrers, 1):
+ referrer_label = escape(row["referrer_label"], quote=False)
+ lines.append(
+ f"{index}. {referrer_label}: {row['count']} приглашений"
+ )
+ else:
+ lines.append("— данных нет")
+
return "\n".join(lines)
def _get_period_range(
@@ -273,87 +384,197 @@ class ReportingService:
start_utc: datetime,
end_utc: datetime,
) -> dict:
- new_trials_result = await session.execute(
- select(func.count(Subscription.id)).where(
- Subscription.created_at >= start_utc,
- Subscription.created_at < end_utc,
- Subscription.is_trial == True, # noqa: E712
- )
- )
- new_trials = int(new_trials_result.scalar() or 0)
-
- direct_paid_result = await session.execute(
- select(func.count(Subscription.id)).where(
- Subscription.created_at >= start_utc,
- Subscription.created_at < end_utc,
- Subscription.is_trial == False, # noqa: E712
- )
- )
- direct_paid = int(direct_paid_result.scalar() or 0)
-
- conversions_result = await session.execute(
- select(func.count(SubscriptionConversion.id)).where(
- SubscriptionConversion.converted_at >= start_utc,
- SubscriptionConversion.converted_at < end_utc,
- )
- )
- conversions_count = int(conversions_result.scalar() or 0)
-
- subscription_payments_row = (
- await session.execute(
- select(
- func.count(Transaction.id),
- func.coalesce(func.sum(Transaction.amount_kopeks), 0),
- ).where(
- Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
- Transaction.is_completed == True, # noqa: E712
- Transaction.created_at >= start_utc,
- Transaction.created_at < end_utc,
+ new_users = int(
+ (
+ await session.execute(
+ select(func.count(User.id)).where(
+ User.created_at >= start_utc,
+ User.created_at < end_utc,
+ )
)
- )
- ).one()
-
- deposits_row = (
- await session.execute(
- select(
- func.count(Transaction.id),
- func.coalesce(func.sum(Transaction.amount_kopeks), 0),
- ).where(
- Transaction.type == TransactionType.DEPOSIT.value,
- Transaction.is_completed == True, # noqa: E712
- Transaction.created_at >= start_utc,
- Transaction.created_at < end_utc,
- )
- )
- ).one()
-
- subscription_payments_count = int(subscription_payments_row[0] or 0)
- subscription_payments_amount = int(subscription_payments_row[1] or 0)
- deposits_count = int(deposits_row[0] or 0)
- deposits_amount = int(deposits_row[1] or 0)
-
- total_payments_count = subscription_payments_count + deposits_count
- total_payments_amount = subscription_payments_amount + deposits_amount
- new_tickets_result = await session.execute(
- select(func.count(Ticket.id)).where(
- Ticket.created_at >= start_utc,
- Ticket.created_at < end_utc,
- )
+ ).scalar()
+ or 0
+ )
+
+ new_trials = int(
+ (
+ await session.execute(
+ select(func.count(Subscription.id)).where(
+ Subscription.created_at >= start_utc,
+ Subscription.created_at < end_utc,
+ Subscription.is_trial == true(),
+ )
+ )
+ ).scalar()
+ or 0
+ )
+
+ direct_paid = int(
+ (
+ await session.execute(
+ select(func.count(Subscription.id)).where(
+ Subscription.created_at >= start_utc,
+ Subscription.created_at < end_utc,
+ Subscription.is_trial == false(),
+ )
+ )
+ ).scalar()
+ or 0
+ )
+
+ trial_to_paid_conversions = int(
+ (
+ await session.execute(
+ select(func.count(SubscriptionConversion.id)).where(
+ SubscriptionConversion.converted_at >= start_utc,
+ SubscriptionConversion.converted_at < end_utc,
+ )
+ )
+ ).scalar()
+ or 0
+ )
+
+ subscription_payments_count, subscription_payments_amount = (
+ (
+ await session.execute(
+ self._txn_query_base(
+ TransactionType.SUBSCRIPTION_PAYMENT.value,
+ start_utc,
+ end_utc,
+ )
+ )
+ ).one()
+ )
+
+ deposits_count, deposits_amount = (
+ (
+ await session.execute(
+ self._deposit_query_excluding_referrals(start_utc, end_utc)
+ )
+ ).one()
+ )
+
+ new_tickets = int(
+ (
+ await session.execute(
+ select(func.count(Ticket.id)).where(
+ Ticket.created_at >= start_utc,
+ Ticket.created_at < end_utc,
+ )
+ )
+ ).scalar()
+ or 0
)
- new_tickets = int(new_tickets_result.scalar() or 0)
return {
+ "new_users": new_users,
"new_trials": new_trials,
- "new_paid_subscriptions": direct_paid + conversions_count,
- "subscription_payments_count": subscription_payments_count,
- "subscription_payments_amount": subscription_payments_amount,
- "deposits_count": deposits_count,
- "deposits_amount": deposits_amount,
- "total_payments_count": total_payments_count,
- "total_payments_amount": total_payments_amount,
+ "new_paid_subscriptions": direct_paid + trial_to_paid_conversions,
+ "trial_to_paid_conversions": trial_to_paid_conversions,
+ "subscription_payments_count": int(subscription_payments_count or 0),
+ "subscription_payments_amount": int(subscription_payments_amount or 0),
+ "deposits_count": int(deposits_count or 0),
+ "deposits_amount": int(deposits_amount or 0),
"new_tickets": new_tickets,
}
+ def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime):
+ return select(
+ func.count(Transaction.id),
+ func.coalesce(func.sum(Transaction.amount_kopeks), 0),
+ ).where(
+ Transaction.type == txn_type,
+ Transaction.is_completed == true(),
+ Transaction.created_at >= start_utc,
+ Transaction.created_at < end_utc,
+ )
+
+ def _deposit_query_excluding_referrals(self, start_utc: datetime, end_utc: datetime):
+ return select(
+ func.count(Transaction.id),
+ func.coalesce(func.sum(Transaction.amount_kopeks), 0),
+ ).where(
+ Transaction.type == TransactionType.DEPOSIT.value,
+ Transaction.is_completed == true(),
+ Transaction.created_at >= start_utc,
+ Transaction.created_at < end_utc,
+ self._exclude_referral_deposits_condition(),
+ )
+
+ async def _get_top_referrers(
+ self,
+ session,
+ start_utc: datetime,
+ end_utc: datetime,
+ limit: int = 5,
+ ) -> List[Dict]:
+ rows = await session.execute(
+ select(
+ User.referred_by_id,
+ func.count(User.id).label("cnt"),
+ )
+ .where(
+ User.created_at >= start_utc,
+ User.created_at < end_utc,
+ User.referred_by_id.isnot(None),
+ )
+ .group_by(User.referred_by_id)
+ .order_by(func.count(User.id).desc())
+ .limit(limit)
+ )
+ rows = rows.all()
+ if not rows:
+ return []
+ ref_ids = [row[0] for row in rows if row[0] is not None]
+ users_map: Dict[int, str] = {}
+ if ref_ids:
+ urows = await session.execute(select(User).where(User.id.in_(ref_ids)))
+ for user in urows.scalars().all():
+ users_map[user.id] = self._user_label(user)
+ return [
+ {"referrer_label": users_map.get(ref_id, f"User #{ref_id}"), "count": int(count or 0)}
+ for ref_id, count in rows
+ ]
+
+ async def _get_user_usage_stats(self, session) -> Dict[str, int]:
+ now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
+
+ active_paid_q = await session.execute(
+ select(func.count(func.distinct(Subscription.user_id))).where(
+ Subscription.is_trial == false(),
+ Subscription.status == SubscriptionStatus.ACTIVE.value,
+ Subscription.end_date > now_utc,
+ )
+ )
+ active_paid_users = int(active_paid_q.scalar() or 0)
+
+ never_connected_q = await session.execute(
+ select(func.count(func.distinct(Subscription.user_id))).where(
+ or_(
+ Subscription.connected_squads.is_(None),
+ func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
+ )
+ )
+ )
+ never_connected_users = int(never_connected_q.scalar() or 0)
+
+ return {
+ "active_paid_users": active_paid_users,
+ "never_connected_users": never_connected_users,
+ }
+
+ def _user_label(self, user: User) -> str:
+ if getattr(user, "username", None):
+ return f"@{user.username}"
+ parts = []
+ if getattr(user, "first_name", None):
+ parts.append(user.first_name)
+ if getattr(user, "last_name", None):
+ parts.append(user.last_name)
+ if parts:
+ return " ".join(parts)
+ return f"User #{getattr(user, 'id', '?')}"
+
def _format_period_label(self, start: datetime, end: datetime) -> str:
start_date = start.astimezone(self._moscow_tz).date()
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
@@ -362,15 +583,10 @@ class ReportingService:
if start_date == end_date:
return start_date.strftime("%d.%m.%Y")
- return (
- f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
- )
+ return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
def _format_amount(self, amount_kopeks: int) -> str:
- if not amount_kopeks:
- return "0 ₽"
-
- rubles = amount_kopeks / 100
+ rubles = (amount_kopeks or 0) / 100
return f"{rubles:,.2f} ₽".replace(",", " ")
diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md
index a6d4109a..4d20e7cb 100644
--- a/docs/project_structure_reference.md
+++ b/docs/project_structure_reference.md
@@ -323,7 +323,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
- Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_suffix`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
+ Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`