From c1aa08b26643ad9a526605eb4529589125882e7a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 07:26:18 +0300 Subject: [PATCH] Add scheduled admin reports and manual sending --- .env.example | 5 + app/bot.py | 2 + app/config.py | 33 +++ app/handlers/admin/reports.py | 85 ++++++++ app/keyboards/admin.py | 12 +- app/services/reporting_service.py | 351 ++++++++++++++++++++++++++++++ main.py | 20 +- 7 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 app/handlers/admin/reports.py create mode 100644 app/services/reporting_service.py diff --git a/.env.example b/.env.example index 2a801dfb..8412d058 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ! ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов +# Автоматические отчеты +ADMIN_REPORTS_ENABLED=false +ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) +ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов +ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал diff --git a/app/bot.py b/app/bot.py index c23bf06d..a13e2237 100644 --- a/app/bot.py +++ b/app/bot.py @@ -38,6 +38,7 @@ from app.handlers.admin import ( backup as admin_backup, welcome_text as admin_welcome_text, tickets as admin_tickets, + reports as admin_reports, ) from app.handlers.stars_payments import register_stars_handlers @@ -139,6 +140,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_backup.register_handlers(dp) admin_welcome_text.register_welcome_text_handlers(dp) admin_tickets.register_handlers(dp) + admin_reports.register_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index 8c8e6f17..063106ce 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,9 @@ +import logging import os import re import html from collections import defaultdict +from datetime import time from typing import List, Optional, Union, Dict from pydantic_settings import BaseSettings from pydantic import field_validator, Field @@ -27,6 +29,11 @@ class Settings(BaseSettings): ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_ENABLED: bool = False + ADMIN_REPORTS_CHAT_ID: Optional[str] = None + ADMIN_REPORTS_TOPIC_ID: Optional[int] = None + ADMIN_REPORTS_SEND_TIME: Optional[str] = None + CHANNEL_SUB_ID: Optional[str] = None CHANNEL_LINK: Optional[str] = None CHANNEL_IS_REQUIRED_SUB: bool = False @@ -425,6 +432,32 @@ class Settings(BaseSettings): def format_price(self, price_kopeks: int) -> str: rubles = price_kopeks // 100 return f"{rubles} ₽" + + def get_reports_chat_id(self) -> Optional[str]: + if self.ADMIN_REPORTS_CHAT_ID: + return self.ADMIN_REPORTS_CHAT_ID + return self.ADMIN_NOTIFICATIONS_CHAT_ID + + def get_reports_topic_id(self) -> Optional[int]: + return self.ADMIN_REPORTS_TOPIC_ID or None + + def get_reports_send_time(self) -> Optional[time]: + value = self.ADMIN_REPORTS_SEND_TIME + if not value: + return None + + try: + hours_str, minutes_str = value.strip().split(":", 1) + hours = int(hours_str) + minutes = int(minutes_str) + if not (0 <= hours <= 23 and 0 <= minutes <= 59): + raise ValueError + return time(hour=hours, minute=minutes) + except (ValueError, AttributeError): + logging.getLogger(__name__).warning( + "Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value + ) + return None def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py new file mode 100644 index 00000000..04075b38 --- /dev/null +++ b/app/handlers/admin/reports.py @@ -0,0 +1,85 @@ +import logging +from aiogram import Dispatcher, F, types +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.keyboards.admin import get_admin_reports_keyboard +from app.services.reporting_service import ( + ReportPeriod, + ReportingServiceError, + reporting_service, +) +from app.utils.decorators import admin_required, error_handler + + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_reports_menu( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await callback.message.edit_text( + "📊 Отчеты\n\n" + "Выберите период, чтобы отправить отчет в админский топик.", + reply_markup=get_admin_reports_keyboard(db_user.language), + parse_mode="HTML", + ) + await callback.answer() + + +@admin_required +@error_handler +async def send_daily_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.DAILY) + + +@admin_required +@error_handler +async def send_weekly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.WEEKLY) + + +@admin_required +@error_handler +async def send_monthly_report( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +) -> None: + await _send_report(callback, ReportPeriod.MONTHLY) + + +async def _send_report(callback: types.CallbackQuery, period: ReportPeriod) -> None: + try: + report_text = await reporting_service.send_report(period, send_to_topic=True) + except ReportingServiceError as exc: + logger.warning("Не удалось отправить отчет: %s", exc) + await callback.answer(str(exc), show_alert=True) + return + except Exception as exc: # noqa: BLE001 + logger.error("Непредвиденная ошибка при отправке отчета: %s", exc) + await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True) + return + + await callback.message.answer(report_text) + await callback.answer("Отчет отправлен в топик") + + +def register_handlers(dp: Dispatcher) -> None: + dp.callback_query.register(show_reports_menu, F.data == "admin_reports") + dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily") + dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly") + dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly") + diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 7b5f1549..0d4cf0d8 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -11,6 +11,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: [InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")], [InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")], [InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")], + [InlineKeyboardButton(text="📊 Отчеты", callback_data="admin_reports")], [InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")], [InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")], [InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")], @@ -111,7 +112,7 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: texts = get_texts(language) - + return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"), @@ -123,6 +124,15 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar ]) +def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📆 За вчера", callback_data="admin_reports_daily")], + [InlineKeyboardButton(text="🗓️ За неделю", callback_data="admin_reports_weekly")], + [InlineKeyboardButton(text="📅 За месяц", callback_data="admin_reports_monthly")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] + ]) + + def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[ [ diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py new file mode 100644 index 00000000..3f9161a1 --- /dev/null +++ b/app/services/reporting_service.py @@ -0,0 +1,351 @@ +import asyncio +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 zoneinfo import ZoneInfo + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from sqlalchemy import func, select + +from app.config import settings +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.database import AsyncSessionLocal +from app.database.models import ( + Subscription, + SubscriptionConversion, + Transaction, + TransactionType, +) + + +logger = logging.getLogger(__name__) + + +class ReportingServiceError(RuntimeError): + """Base error for the reporting service.""" + + +class ReportPeriod(Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + + +@dataclass(slots=True) +class ReportPeriodRange: + start_msk: datetime + end_msk: datetime + label: str + + +class ReportingService: + """Generates admin summary reports and can schedule daily delivery.""" + + def __init__(self) -> None: + self.bot: Optional[Bot] = None + self._task: Optional[asyncio.Task] = None + self._moscow_tz = ZoneInfo("Europe/Moscow") + + def set_bot(self, bot: Bot) -> None: + self.bot = bot + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.ADMIN_REPORTS_ENABLED: + logger.info("Сервис отчетов отключен настройками") + return + + if not self.bot: + logger.warning("Невозможно запустить сервис отчетов без экземпляра бота") + return + + chat_id = settings.get_reports_chat_id() + if not chat_id: + logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов") + return + + send_time = settings.get_reports_send_time() + if not send_time: + logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки") + return + + self._task = asyncio.create_task(self._auto_daily_loop(send_time)) + logger.info( + "📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК", + send_time.strftime("%H:%M"), + ) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def send_report( + self, + period: ReportPeriod, + *, + report_date: Optional[date] = None, + send_to_topic: bool = False, + ) -> str: + report_text = await self._build_report(period, report_date) + + if send_to_topic: + await self._deliver_report(report_text) + + return report_text + + async def _auto_daily_loop(self, send_time: datetime_time) -> None: + try: + next_run_utc, report_date = self._calculate_next_run(send_time) + + while True: + now_utc = datetime.now(timezone.utc) + delay = (next_run_utc - now_utc).total_seconds() + + if delay > 0: + await asyncio.sleep(delay) + + try: + await self.send_report( + ReportPeriod.DAILY, + report_date=report_date, + send_to_topic=True, + ) + logger.info( + "📊 Автоматический отчет за %s отправлен", + report_date.strftime("%d.%m.%Y"), + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка автоматической отправки отчета: %s", exc) + + next_run_utc, report_date = self._calculate_next_run(send_time) + + except asyncio.CancelledError: + logger.info("Сервис отчетов остановлен") + raise + except Exception as exc: # noqa: BLE001 + logger.error("Критическая ошибка в сервисе отчетов: %s", exc) + + def _calculate_next_run( + self, + send_time: datetime_time, + ) -> Tuple[datetime, date]: + now_msk = datetime.now(self._moscow_tz) + candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz) + + if now_msk >= candidate: + candidate += timedelta(days=1) + + report_date = (candidate - timedelta(days=1)).date() + return candidate.astimezone(timezone.utc), report_date + + async def _deliver_report(self, report_text: str) -> None: + if not self.bot: + raise ReportingServiceError("Бот не инициализирован для отправки отчета") + + chat_id = settings.get_reports_chat_id() + if not chat_id: + raise ReportingServiceError("Не задан чат для отправки отчета") + + topic_id = settings.get_reports_topic_id() + + try: + await self.bot.send_message( + chat_id=chat_id, + text=report_text, + message_thread_id=topic_id, + ) + except (TelegramBadRequest, TelegramForbiddenError) as exc: + logger.error("Не удалось отправить отчет: %s", exc) + raise ReportingServiceError("Не удалось отправить отчет в чат") from exc + + async def _build_report( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> str: + period_range = self._get_period_range(period, report_date) + start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None) + end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None) + + async with AsyncSessionLocal() as session: + totals = await self._collect_current_totals(session) + period_stats = await self._collect_period_stats(session, start_utc, end_utc) + + header = ( + f"📊 Отчет за {period_range.label}" + if period == ReportPeriod.DAILY + else f"📊 Отчет за период {period_range.label}" + ) + + lines = [ + header, + "", + "🎯 Триалы", + f"• Активных сейчас: {totals['active_trials']}", + f"• Новых за период: {period_stats['new_trials']}", + "", + "💎 Платные подписки", + f"• Активных сейчас: {totals['active_paid']}", + f"• Новых за период: {period_stats['new_paid_subscriptions']}", + "", + "💰 Платежи", + 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'])}", + ] + + return "\n".join(lines) + + def _get_period_range( + self, + period: ReportPeriod, + report_date: Optional[date], + ) -> ReportPeriodRange: + now_msk = datetime.now(self._moscow_tz) + + if period == ReportPeriod.DAILY: + target_date = report_date or (now_msk.date() - timedelta(days=1)) + start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz) + end = start + timedelta(days=1) + elif period == ReportPeriod.WEEKLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=7) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + elif period == ReportPeriod.MONTHLY: + end_date = report_date or now_msk.date() + start_date = end_date - timedelta(days=30) + start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) + end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) + else: # pragma: no cover - defensive branch + raise ReportingServiceError(f"Неизвестный период отчета: {period}") + + label = self._format_period_label(start, end) + return ReportPeriodRange(start, end, label) + + async def _collect_current_totals(self, session) -> dict: + stats = await get_subscriptions_statistics(session) + return { + "active_trials": stats.get("trial_subscriptions", 0) or 0, + "active_paid": stats.get("paid_subscriptions", 0) or 0, + } + + async def _collect_period_stats( + self, + session, + 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, + ) + ) + ).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 + + return { + "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, + } + + 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) + end_date = end_boundary.date() + + 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')}" + ) + + def _format_amount(self, amount_kopeks: int) -> str: + if not amount_kopeks: + return "0 ₽" + + rubles = amount_kopeks / 100 + return f"{rubles:,.2f} ₽".replace(",", " ") + + +reporting_service = ReportingService() + diff --git a/main.py b/main.py index 4be13cde..254e5c5d 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from app.external.yookassa_webhook import start_yookassa_webhook_server from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer from app.database.universal_migration import run_universal_migration from app.services.backup_service import backup_service +from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates @@ -111,7 +112,14 @@ async def main(): logger.info("✅ Сервис бекапов инициализирован") except Exception as e: logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}") - + + logger.info("📊 Инициализация сервиса отчетов...") + try: + reporting_service.set_bot(bot) + await reporting_service.start() + except Exception as e: + logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}") + payment_service = PaymentService(bot) webhook_needed = ( @@ -188,6 +196,10 @@ async def main(): logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}") logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}") logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}") + logger.info( + " Отчеты: %s", + "Включен" if reporting_service.is_running() else "Отключен", + ) logger.info("=" * 50) try: @@ -277,6 +289,12 @@ async def main(): except asyncio.CancelledError: pass + logger.info("ℹ️ Остановка сервиса отчетов...") + try: + await reporting_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup()