From b7e9770588c9ecdcedd13c1b99a45e6b706f5f31 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:00:08 +0300 Subject: [PATCH 1/6] Handle back navigation after simple subscription QR --- app/handlers/subscription/purchase.py | 50 ++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 58bad0b7..6b4b3937 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 @@ -528,11 +529,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 +559,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, From f5881919318b7e129627a2c52a76dfd8013584a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:05:44 +0300 Subject: [PATCH 2/6] Fix subscription back navigation restoring configuration --- app/handlers/subscription/autopay.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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) From 0e43daf48b5d81cb143936c22ee5afcc0b64137c Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:10:26 +0300 Subject: [PATCH 3/6] Fix autopay day button label --- app/keyboards/inline.py | 15 +++++++-------- docs/project_structure_reference.md | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) 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/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` From 630688ecf52aae90eb84487c683cbadf42942269 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:25:53 +0300 Subject: [PATCH 4/6] feat: enhance reporting statistics --- app/services/reporting_service.py | 422 ++++++++++++++++++++++-------- 1 file changed, 317 insertions(+), 105 deletions(-) diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index b0cc9505..55334312 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -3,13 +3,15 @@ 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 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 +19,12 @@ from app.database.database import AsyncSessionLocal from app.database.models import ( Subscription, SubscriptionConversion, + SubscriptionStatus, Ticket, TicketStatus, Transaction, TransactionType, + User, ) @@ -45,7 +49,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 +174,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 +243,88 @@ 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): + lines.append(f"{index}. {row['referrer_label']}: {row['count']} приглашений") + else: + lines.append("— данных нет") + return "\n".join(lines) def _get_period_range( @@ -273,87 +380,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 +579,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(",", " ") From 078e59f09cfd537d42eeb75e90e6f6200de6d457 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:31:18 +0300 Subject: [PATCH 5/6] Fix syntax issues in subscription purchase handler --- app/handlers/subscription/purchase.py | 71 ++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 6b4b3937..99bf3e7a 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -81,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, @@ -2352,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: # Если баланс достаточный, предлагаем оплатить с баланса @@ -2359,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" @@ -2377,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" From f62f37581ff14aace97333e9bb8eccf185f63105 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 09:36:30 +0300 Subject: [PATCH 6/6] Escape referrer names in HTML report --- app/services/reporting_service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index 55334312..af0697be 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -3,6 +3,7 @@ import logging from dataclasses import dataclass from datetime import date, datetime, time as datetime_time, timedelta, timezone from enum import Enum +from html import escape from typing import Dict, List, Optional, Tuple from zoneinfo import ZoneInfo @@ -321,7 +322,10 @@ class ReportingService: lines += ["🤝 Топ по рефералам (за период)"] if top_referrers: for index, row in enumerate(top_referrers, 1): - lines.append(f"{index}. {row['referrer_label']}: {row['count']} приглашений") + referrer_label = escape(row["referrer_label"], quote=False) + lines.append( + f"{index}. {referrer_label}: {row['count']} приглашений" + ) else: lines.append("— данных нет")