mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-20 03:11:47 +00:00
@@ -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)
|
||||
|
||||
@@ -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"⚡ <b>Простая покупка подписки</b>\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"⚡ <b>Простая покупка подписки</b>\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"
|
||||
|
||||
@@ -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 "дней"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"📊 <b>Отчет за {period_range.label}</b>"
|
||||
if period == ReportPeriod.DAILY
|
||||
else f"📊 <b>Отчет за период {period_range.label}</b>"
|
||||
)
|
||||
lines += [header, ""]
|
||||
|
||||
lines = [
|
||||
header,
|
||||
# TL;DR
|
||||
lines += [
|
||||
"🧭 <b>Итог по периоду</b>",
|
||||
f"• Новых пользователей: <b>{stats['new_users']}</b>",
|
||||
f"• Новых триалов: <b>{stats['new_trials']}</b>",
|
||||
(
|
||||
f"• Конверсий триал → платная: <b>{stats['trial_to_paid_conversions']}</b> "
|
||||
f"(<i>{conversion_rate:.1f}%</i>)"
|
||||
),
|
||||
f"• Новых платных (всего): <b>{stats['new_paid_subscriptions']}</b>",
|
||||
f"• Поступления всего (только пополнения): <b>{self._format_amount(stats['deposits_amount'])}</b>",
|
||||
"",
|
||||
"🎯 <b>Триалы</b>",
|
||||
f"• Активных сейчас: {totals['active_trials']}",
|
||||
f"• Новых за период: {period_stats['new_trials']}",
|
||||
"",
|
||||
"💎 <b>Платные подписки</b>",
|
||||
f"• Активных сейчас: {totals['active_paid']}",
|
||||
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
|
||||
"",
|
||||
"🎟️ <b>Тикеты поддержки</b>",
|
||||
f"• Активных сейчас: {totals['open_tickets']}",
|
||||
f"• Новых за период: {period_stats['new_tickets']}",
|
||||
"",
|
||||
"💰 <b>Платежи</b>",
|
||||
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 += [
|
||||
"💎 <b>Подписки</b>",
|
||||
f"• Активные триалы сейчас: {totals['active_trials']}",
|
||||
f"• Активные платные сейчас: {totals['active_paid']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Финансы
|
||||
lines += [
|
||||
"💰 <b>Финансы</b>",
|
||||
(
|
||||
"• Оплаты подписок: "
|
||||
f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}"
|
||||
),
|
||||
(
|
||||
"• Пополнения: "
|
||||
f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}"
|
||||
),
|
||||
(
|
||||
"<i>Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы "
|
||||
"исключены.</i>"
|
||||
),
|
||||
"",
|
||||
]
|
||||
|
||||
# Поддержка
|
||||
lines += [
|
||||
"🎟️ <b>Поддержка</b>",
|
||||
f"• Новых тикетов: {stats['new_tickets']}",
|
||||
f"• Активных тикетов сейчас: {totals['open_tickets']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Активность пользователей
|
||||
lines += [
|
||||
"👤 <b>Активность пользователей</b>",
|
||||
f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}",
|
||||
f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Топ по рефералам
|
||||
lines += ["🤝 <b>Топ по рефералам (за период)</b>"]
|
||||
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(",", " ")
|
||||
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user