diff --git a/app/config.py b/app/config.py index 6da1388c..025e4719 100644 --- a/app/config.py +++ b/app/config.py @@ -7,6 +7,7 @@ import html from collections import defaultdict from datetime import time from typing import List, Optional, Union, Dict +from zoneinfo import ZoneInfo from pydantic_settings import BaseSettings from pydantic import field_validator, Field from pathlib import Path @@ -60,6 +61,8 @@ class Settings(BaseSettings): SQLITE_PATH: str = "./data/bot.db" LOCALES_PATH: str = "./locales" + + TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC")) DATABASE_MODE: str = "auto" @@ -1424,11 +1427,23 @@ class Settings(BaseSettings): model_config = { "env_file": ".env", "env_file_encoding": "utf-8", - "extra": "ignore" + "extra": "ignore" } + @field_validator("TIMEZONE") + @classmethod + def validate_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except Exception as exc: # pragma: no cover - defensive validation + raise ValueError( + f"Некорректный идентификатор часового пояса: {value}" + ) from exc + return value + settings = Settings() +ENV_OVERRIDE_KEYS = set(settings.model_fields_set) _PERIOD_PRICE_FIELDS: Dict[int, str] = { 14: "PRICE_14_DAYS", diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 7421ff72..6f5691fe 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -15,6 +15,7 @@ from app.database.models import ( from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months from app.config import settings +from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) @@ -1131,7 +1132,13 @@ async def check_and_update_subscription_status( current_time = datetime.utcnow() - logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}") + logger.info( + "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", + subscription.id, + subscription.status, + format_local_datetime(subscription.end_date), + format_local_datetime(current_time), + ) if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 8898c03c..3d4bebfc 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -42,7 +42,16 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "title": "🤖 Основные", "description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.", "icon": "🤖", - "categories": ("CORE", "CHANNEL"), + "categories": ( + "CORE", + "CHANNEL", + "TIMEZONE", + "DATABASE", + "POSTGRES", + "SQLITE", + "REDIS", + "REMNAWAVE", + ), }, "support": { "title": "💬 Поддержка", @@ -56,6 +65,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "icon": "💳", "categories": ( "PAYMENT", + "PAYMENT_VERIFICATION", "YOOKASSA", "CRYPTOBOT", "HELEKET", @@ -114,18 +124,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "ADDITIONAL", ), }, - "database": { - "title": "💾 База данных", - "description": "Режим базы, параметры PostgreSQL, SQLite и Redis.", - "icon": "💾", - "categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"), - }, - "remnawave": { - "title": "🌐 RemnaWave API", - "description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.", - "icon": "🌐", - "categories": ("REMNAWAVE",), - }, "server": { "title": "📊 Статус серверов", "description": "Мониторинг серверов, SLA и внешние метрики.", @@ -142,13 +140,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "title": "⚡ Расширенные", "description": "Web API, webhook, логирование, модерация и режим отладки.", "icon": "⚡", - "categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"), - }, - "external_admin": { - "title": "🛡️ Внешняя админка", - "description": "Токен, по которому внешняя админка проверяет запросы.", - "icon": "🛡️", - "categories": ("EXTERNAL_ADMIN",), + "categories": ( + "WEB_API", + "WEBHOOK", + "LOG", + "MODERATION", + "DEBUG", + "EXTERNAL_ADMIN", + ), }, } @@ -161,12 +160,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( "referral", "notifications", "interface", - "database", - "remnawave", "server", "maintenance", "advanced", - "external_admin", ) CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple( diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 6b791efe..cf2ceab1 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -38,6 +38,7 @@ from app.utils.promo_offer import ( from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService from app.services.faq_service import FaqService +from app.utils.timezone import format_local_datetime from app.utils.pricing_utils import format_period_description logger = logging.getLogger(__name__) @@ -952,7 +953,8 @@ def _get_subscription_status(user: User, texts) -> str: current_time = datetime.utcnow() actual_status = (subscription.actual_status or "").lower() - end_date_text = subscription.end_date.strftime("%d.%m.%Y") + end_date = getattr(subscription, "end_date", None) + end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None days_left = 0 if subscription.end_date > current_time: @@ -968,10 +970,10 @@ def _get_subscription_status(user: User, texts) -> str: return texts.t( "SUB_STATUS_EXPIRED", "🔴 Истекла\n📅 {end_date}", - ).format(end_date=end_date_text) + ).format(end_date=end_date_text or "—") if actual_status == "trial": - if days_left > 1: + if days_left > 1 and end_date_text: return texts.t( "SUB_STATUS_TRIAL_ACTIVE", "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", @@ -990,7 +992,7 @@ def _get_subscription_status(user: User, texts) -> str: ) if actual_status == "active": - if days_left > 7: + if days_left > 7 and end_date_text: return texts.t( "SUB_STATUS_ACTIVE_LONG", "💎 Активна\n📅 до {end_date} ({days} дн.)", diff --git a/app/handlers/start.py b/app/handlers/start.py index 087f560f..abeeb10a 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -37,6 +37,7 @@ from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, ) +from app.utils.timezone import format_local_datetime from app.database.crud.user_message import get_random_active_message from app.database.crud.subscription import decrement_subscription_server_counts @@ -1268,6 +1269,7 @@ def _get_subscription_status(user, texts): from datetime import datetime end_date = getattr(subscription, "end_date", None) + end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None current_time = datetime.utcnow() if actual_status == "disabled": @@ -1277,11 +1279,11 @@ def _get_subscription_status(user, texts): return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации") if actual_status == "expired" or (end_date and end_date <= current_time): - if end_date: + if end_date_display: return texts.t( "SUB_STATUS_EXPIRED", "🔴 Истекла\n📅 {end_date}", - ).format(end_date=end_date.strftime('%d.%m.%Y')) + ).format(end_date=end_date_display) return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла") if not end_date: @@ -1294,11 +1296,11 @@ def _get_subscription_status(user, texts): return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен") if is_trial: - if days_left > 1: + if days_left > 1 and end_date_display: return texts.t( "SUB_STATUS_TRIAL_ACTIVE", "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", - ).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left) + ).format(end_date=end_date_display, days=days_left) if days_left == 1: return texts.t( "SUB_STATUS_TRIAL_TOMORROW", @@ -1309,11 +1311,11 @@ def _get_subscription_status(user, texts): "🎁 Тестовая подписка\n⚠️ истекает сегодня!", ) - if days_left > 7: + if days_left > 7 and end_date_display: return texts.t( "SUB_STATUS_ACTIVE_LONG", "💎 Активна\n📅 до {end_date} ({days} дн.)", - ).format(end_date=end_date.strftime('%d.%m.%Y'), days=days_left) + ).format(end_date=end_date_display, days=days_left) if days_left > 1: return texts.t( "SUB_STATUS_ACTIVE_FEW_DAYS", diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index 61d98c1d..bd04165e 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -77,6 +77,7 @@ from app.utils.promo_offer import ( build_promo_offer_hint, get_user_active_promo_discount_percent, ) +from app.utils.timezone import format_local_datetime from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback @@ -491,7 +492,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess info_text = info_template.format( status=status_text, type=type_text, - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), days_left=max(0, subscription.days_left), traffic_used=texts.format_traffic(subscription.traffic_used_gb), traffic_limit=traffic_text, diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index f85536ab..06df0798 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -77,6 +77,7 @@ from app.utils.subscription_utils import ( get_happ_cryptolink_redirect_link, resolve_simple_subscription_device_limit, ) +from app.utils.timezone import format_local_datetime from app.utils.promo_offer import ( build_promo_offer_hint, get_user_active_promo_discount_percent, @@ -301,7 +302,7 @@ async def show_subscription_info( status_display=status_display, warning=warning_text, subscription_type=subscription_type, - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), time_left=time_left_text, traffic=traffic_used_display, servers=servers_display, @@ -1333,7 +1334,7 @@ async def confirm_extend_subscription( success_message = ( "✅ Подписка успешно продлена!\n\n" f"⏰ Добавлено: {days} дней\n" - f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n" + f"Действует до: {format_local_datetime(refreshed_end_date, '%d.%m.%Y %H:%M')}\n\n" f"💰 Списано: {texts.format_price(price)}" ) @@ -2983,7 +2984,7 @@ async def _extend_existing_subscription( success_message = ( "✅ Подписка успешно продлена!\n\n" f"⏰ Добавлено: {period_days} дней\n" - f"Действует до: {new_end_date.strftime('%d.%m.%Y %H:%M')}\n\n" + f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n" f"💰 Списано: {texts.format_price(price_kopeks)}" ) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index de7407b9..3f757d6e 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -19,6 +19,7 @@ from app.database.models import ( TransactionType, User, ) +from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) @@ -224,10 +225,10 @@ class AdminNotificationService: 📱 Устройства: {trial_device_limit} 🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'} -📆 Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')} +📆 Действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')} 🔗 Реферер: {referrer_info} -⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}""" +⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" return await self._send_message(message) @@ -288,11 +289,11 @@ class AdminNotificationService: 📱 Устройства: {subscription.device_limit} 🌐 Серверы: {servers_info} -📆 Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')} +📆 Действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')} 💰 Баланс после покупки: {settings.format_price(user.balance_kopeks)} 🔗 Реферер: {referrer_info} -⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}""" +⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" return await self._send_message(message) @@ -339,7 +340,7 @@ class AdminNotificationService: ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория. - ⚙️ Автоматическая проверка обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}""" + ⚙️ Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" return await self._send_message(message) @@ -364,7 +365,7 @@ class AdminNotificationService: 🔄 Следующая попытка через час. ⚙️ Проверьте доступность GitHub API и настройки сети. - ⚙️ Система автоматических обновлений • {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}""" + ⚙️ Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" return await self._send_message(message) @@ -387,7 +388,7 @@ class AdminNotificationService: balance_change = user.balance_kopeks - old_balance subscription_status = self._get_subscription_status(subscription) promo_block = self._format_promo_group_block(promo_group) - timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S') + timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S') user_display = self._get_user_display(user) return f"""💰 ПОПОЛНЕНИЕ БАЛАНСА @@ -585,8 +586,8 @@ class AdminNotificationService: 📅 Продление: ➕ Добавлено дней: {extended_days} -📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')} -📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')} +📆 Было до: {format_local_datetime(old_end_date, '%d.%m.%Y %H:%M')} +📆 Стало до: {format_local_datetime(current_end_date, '%d.%m.%Y %H:%M')} 📱 Текущие параметры: 📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)} @@ -595,7 +596,7 @@ class AdminNotificationService: 💰 Баланс после операции: {settings.format_price(current_balance)} -⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}""" +⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" return await self._send_message(message) @@ -648,7 +649,7 @@ class AdminNotificationService: valid_until = promocode_data.get("valid_until") if valid_until: message_lines.append( - f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}" + f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}" if isinstance(valid_until, datetime) else f"⏳ Действует до: {valid_until}" ) @@ -659,7 +660,7 @@ class AdminNotificationService: "📝 Эффект:", effect_description.strip() or "✅ Промокод активирован", "", - f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", ] ) @@ -713,7 +714,7 @@ class AdminNotificationService: message_lines.extend( [ "", - f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", ] ) @@ -778,7 +779,7 @@ class AdminNotificationService: [ "", f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}", - f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", ] ) @@ -856,9 +857,9 @@ class AdminNotificationService: return "❌ Нет подписки" if subscription.is_trial: - return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})" + return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})" elif subscription.is_active: - return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})" + return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})" else: return "❌ Неактивна" @@ -929,7 +930,9 @@ class AdminNotificationService: if isinstance(enabled_at, str): from datetime import datetime enabled_at = datetime.fromisoformat(enabled_at) - message_parts.append(f"🕐 Время включения: {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}") + message_parts.append( + f"🕐 Время включения: {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}" + ) message_parts.append(f"🤖 Автоматически: {'Да' if details.get('auto_enabled', False) else 'Нет'}") message_parts.append("") @@ -941,7 +944,9 @@ class AdminNotificationService: if isinstance(disabled_at, str): from datetime import datetime disabled_at = datetime.fromisoformat(disabled_at) - message_parts.append(f"🕐 Время отключения: {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}") + message_parts.append( + f"🕐 Время отключения: {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}" + ) if details.get("duration"): duration = details["duration"] @@ -1000,9 +1005,10 @@ class AdminNotificationService: else: message_parts.append("Автоматический мониторинг API остановлен.") - from datetime import datetime message_parts.append("") - message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + message_parts.append( + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}" + ) message = "\n".join(message_parts) @@ -1048,7 +1054,9 @@ class AdminNotificationService: if isinstance(last_check, str): from datetime import datetime last_check = datetime.fromisoformat(last_check) - message_parts.append(f"🕐 Последняя проверка: {last_check.strftime('%H:%M:%S')}") + message_parts.append( + f"🕐 Последняя проверка: {format_local_datetime(last_check, '%H:%M:%S')}" + ) if status == "online": if details.get("uptime"): @@ -1094,9 +1102,10 @@ class AdminNotificationService: message_parts.append("") message_parts.append("Панель временно недоступна для обслуживания.") - from datetime import datetime message_parts.append("") - message_parts.append(f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}") + message_parts.append( + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}" + ) message = "\n".join(message_parts) @@ -1171,11 +1180,11 @@ class AdminNotificationService: message_lines.extend( [ "", - f"📅 Подписка действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}", + f"📅 Подписка действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}", f"💰 Баланс после операции: {settings.format_price(user.balance_kopeks)}", f"🔗 Рефер: {referrer_info}", "", - f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", ] ) diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py index df183d0e..7180a2d1 100644 --- a/app/services/maintenance_service.py +++ b/app/services/maintenance_service.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from app.config import settings from app.external.remnawave_api import RemnaWaveAPI, test_api_connection from app.utils.cache import cache +from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) @@ -45,6 +46,9 @@ class MaintenanceService: def get_maintenance_message(self) -> str: if self._status.auto_enabled: + last_check_display = format_local_datetime( + self._status.last_check, "%H:%M:%S", "неизвестно" + ) return f""" 🔧 Технические работы! @@ -52,7 +56,7 @@ class MaintenanceService: ⏰ Мы работаем над восстановлением. Попробуйте через несколько минут. -🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'} +🔄 Последняя проверка: {last_check_display} """ else: return settings.get_maintenance_message() @@ -79,7 +83,12 @@ class MaintenanceService: } emoji = emoji_map.get(alert_type, "ℹ️") - formatted_message = f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" + timestamp = format_local_datetime( + datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z" + ) + formatted_message = ( + f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}" + ) return await notification_service._send_message(formatted_message) @@ -152,11 +161,14 @@ class MaintenanceService: await self._save_status_to_cache() + enabled_time = format_local_datetime( + self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z" + ) notification_msg = f"""Режим технических работ ВКЛЮЧЕН 📋 Причина: {self._status.reason} 🤖 Автоматически: {'Да' if auto else 'Нет'} -🕐 Время: {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')} +🕐 Время: {enabled_time} Обычные пользователи временно не смогут использовать бота.""" @@ -197,10 +209,13 @@ class MaintenanceService: else: duration_str = f"\n⏱️ Длительность: {minutes}мин" + notification_time = format_local_datetime( + datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z" + ) notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН 🤖 Автоматически: {'Да' if was_auto else 'Нет'} -🕐 Время: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} +🕐 Время: {notification_time} {duration_str} Сервис снова доступен для пользователей.""" @@ -229,14 +244,17 @@ class MaintenanceService: settings.get_maintenance_retry_attempts(), ) - await self._notify_admins(f"""Мониторинг технических работ запущен + await self._notify_admins( + f"""Мониторинг технических работ запущен 🔄 Интервал проверки: {settings.get_maintenance_check_interval()} секунд 🤖 Автовключение: {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'} 🎯 Порог ошибок: {self._max_consecutive_failures} 🔁 Повторных попыток: {settings.get_maintenance_retry_attempts()} -Система будет следить за доступностью API.""", "info") +Система будет следить за доступностью API.""", + "info", + ) return True @@ -291,13 +309,19 @@ class MaintenanceService: ) if not self._status.api_status: - await self._notify_admins(f"""API Remnawave восстановлено! + recovery_time = format_local_datetime( + self._status.last_check, "%H:%M:%S %Z" + ) + await self._notify_admins( + f"""API Remnawave восстановлено! ✅ Статус: Доступно -🕐 Время восстановления: {self._status.last_check.strftime('%H:%M:%S')} +🕐 Время восстановления: {recovery_time} 🔄 Неудачных попыток было: {self._status.consecutive_failures} -API снова отвечает на запросы.""", "success") +API снова отвечает на запросы.""", + "success", + ) self._status.api_status = True self._status.consecutive_failures = 0 @@ -321,13 +345,19 @@ API снова отвечает на запросы.""", "success") self._status.consecutive_failures += 1 if was_available: - await self._notify_admins(f"""API Remnawave недоступно! + detection_time = format_local_datetime( + self._status.last_check, "%H:%M:%S %Z" + ) + await self._notify_admins( + f"""API Remnawave недоступно! ❌ Статус: Недоступно -🕐 Время обнаружения: {self._status.last_check.strftime('%H:%M:%S')} +🕐 Время обнаружения: {detection_time} 🔄 Попытка: {self._status.consecutive_failures} -Началась серия неудачных проверок API.""", "error") +Началась серия неудачных проверок API.""", + "error", + ) if ( self._status.consecutive_failures >= self._max_consecutive_failures @@ -349,12 +379,16 @@ API снова отвечает на запросы.""", "success") logger.error(f"Ошибка проверки API: {e}") if self._status.api_status: - await self._notify_admins(f"""Ошибка при проверке API Remnawave + error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z") + await self._notify_admins( + f"""Ошибка при проверке API Remnawave ❌ Ошибка: {str(e)} -🕐 Время: {datetime.utcnow().strftime('%H:%M:%S')} +🕐 Время: {error_time} -Не удалось выполнить проверку доступности API.""", "error") +Не удалось выполнить проверку доступности API.""", + "error", + ) self._status.api_status = False self._status.consecutive_failures += 1 diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 10e4cfb5..f1787eb0 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -38,6 +38,7 @@ from app.database.crud.user import ( subtract_user_balance, cleanup_expired_promo_offer_discounts, ) +from app.utils.timezone import format_local_datetime from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) @@ -1035,7 +1036,7 @@ class MonitoringService: message = f""" ⚠️ Подписка истекает через {days_text}! -Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}. +Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}. 💳 Автоплатеж: {autopay_status} @@ -1151,7 +1152,7 @@ class MonitoringService: message = template.format( price=settings.format_price(settings.PRICE_30_DAYS), - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), ) from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton @@ -1267,7 +1268,7 @@ class MonitoringService: ), ) message = template.format( - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), price=settings.format_price(settings.PRICE_30_DAYS), ) @@ -1344,7 +1345,7 @@ class MonitoringService: message = template.format( percent=percent, - expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"), trigger_days=trigger_days or "", ) diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 72607382..e1bf5802 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1,6 +1,5 @@ import asyncio import logging -import os import re from contextlib import AsyncExitStack, asynccontextmanager from datetime import datetime, timedelta @@ -42,6 +41,7 @@ from app.database.models import ( from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.timezone import get_local_timezone logger = logging.getLogger(__name__) @@ -59,15 +59,7 @@ class RemnaWaveService: self._config_error: Optional[str] = None - tz_name = os.getenv("TZ", "UTC") - try: - self._panel_timezone = ZoneInfo(tz_name) - except Exception: - logger.warning( - "⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.", - tz_name, - ) - self._panel_timezone = ZoneInfo("UTC") + self._panel_timezone = get_local_timezone() if not base_url: self._config_error = "REMNAWAVE_API_URL не настроен" diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 71b53fd5..d546f15e 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -29,6 +29,7 @@ from app.services.subscription_purchase_service import ( from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service from app.utils.pricing_utils import format_period_description +from app.utils.timezone import format_local_datetime logger = logging.getLogger(__name__) @@ -333,7 +334,7 @@ async def _auto_extend_subscription( getattr(user, "language", "ru"), ) new_end_date = updated_subscription.end_date - end_date_label = new_end_date.strftime("%d.%m.%Y %H:%M") + end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M") if bot: try: diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 34a74bae..8e8f5e42 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -9,7 +9,13 @@ from app.database.universal_migration import ensure_default_web_api_token from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices +from app.config import ( + Settings, + settings, + refresh_period_prices, + refresh_traffic_prices, + ENV_OVERRIDE_KEYS, +) from app.database.crud.system_setting import ( delete_system_setting, upsert_system_setting, @@ -71,6 +77,7 @@ class BotConfigurationService: "SUPPORT": "💬 Поддержка и тикеты", "LOCALIZATION": "🌍 Языки интерфейса", "CHANNEL": "📣 Обязательная подписка", + "TIMEZONE": "🗂 Timezone", "PAYMENT": "💳 Общие платежные настройки", "PAYMENT_VERIFICATION": "🕵️ Проверка платежей", "TELEGRAM": "⭐ Telegram Stars", @@ -124,6 +131,7 @@ class BotConfigurationService: "SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.", "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.", "CHANNEL": "Настройки обязательной подписки на канал или группу.", + "TIMEZONE": "Часовой пояс панели и отображение времени.", "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.", "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.", "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.", @@ -631,6 +639,10 @@ class BotConfigurationService: def is_read_only(cls, key: str) -> bool: return key in cls.READ_ONLY_KEYS + @classmethod + def _is_env_override(cls, key: str) -> bool: + return key in cls._env_override_keys + @classmethod def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: if isinstance(value, bool): @@ -744,6 +756,7 @@ class BotConfigurationService: _definitions: Dict[str, SettingDefinition] = {} _original_values: Dict[str, Any] = settings.model_dump() _overrides_raw: Dict[str, Optional[str]] = {} + _env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS) _callback_tokens: Dict[str, str] = {} _token_to_key: Dict[str, str] = {} _choice_tokens: Dict[str, Dict[Any, str]] = {} @@ -867,6 +880,8 @@ class BotConfigurationService: @classmethod def has_override(cls, key: str) -> bool: + if cls._is_env_override(key): + return False return key in cls._overrides_raw @classmethod @@ -1162,6 +1177,12 @@ class BotConfigurationService: overrides[row.key] = row.value for key, raw_value in overrides.items(): + if cls._is_env_override(key): + logger.debug( + "Пропускаем настройку %s из БД: используется значение из окружения", + key, + ) + continue try: parsed_value = cls.deserialize_value(key, raw_value) except Exception as error: @@ -1281,8 +1302,15 @@ class BotConfigurationService: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) - cls._overrides_raw[key] = raw_value - cls._apply_to_settings(key, value) + if cls._is_env_override(key): + logger.info( + "Настройка %s сохранена в БД, но не применена: значение задаётся через окружение", + key, + ) + cls._overrides_raw.pop(key, None) + else: + cls._overrides_raw[key] = raw_value + cls._apply_to_settings(key, value) if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @@ -1300,14 +1328,26 @@ class BotConfigurationService: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) - original = cls.get_original_value(key) - cls._apply_to_settings(key, original) + if cls._is_env_override(key): + logger.info( + "Настройка %s сброшена в БД, используется значение из окружения", + key, + ) + else: + original = cls.get_original_value(key) + cls._apply_to_settings(key, original) if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: await cls._sync_default_web_api_token() @classmethod def _apply_to_settings(cls, key: str, value: Any) -> None: + if cls._is_env_override(key): + logger.debug( + "Пропуск применения настройки %s: значение задано через окружение", + key, + ) + return try: setattr(settings, key, value) if key in { diff --git a/app/services/user_service.py b/app/services/user_service.py index 9d551fec..0a28c428 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -21,7 +21,7 @@ from app.database.models import ( User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse, ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText, - SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, + SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, TransactionType ) @@ -779,6 +779,29 @@ class UserService: except Exception as e: logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}") + try: + heleket_result = await db.execute( + select(HeleketPayment).where(HeleketPayment.user_id == user_id) + ) + heleket_payments = heleket_result.scalars().all() + + if heleket_payments: + logger.info( + f"🔄 Удаляем {len(heleket_payments)} Heleket платежей" + ) + await db.execute( + update(HeleketPayment) + .where(HeleketPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(HeleketPayment).where(HeleketPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Heleket платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) diff --git a/app/utils/timezone.py b/app/utils/timezone.py new file mode 100644 index 00000000..83af1aa1 --- /dev/null +++ b/app/utils/timezone.py @@ -0,0 +1,82 @@ +"""Timezone utilities for consistent local time handling.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone as dt_timezone +from functools import lru_cache +from typing import Optional +from zoneinfo import ZoneInfo + +from app.config import settings + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=1) +def get_local_timezone() -> ZoneInfo: + """Return the configured local timezone. + + Falls back to UTC if the configured timezone cannot be loaded. The + fallback is logged once and cached for subsequent calls. + """ + + tz_name = settings.TIMEZONE + + try: + return ZoneInfo(tz_name) + except Exception as exc: # pragma: no cover - defensive branch + logger.warning( + "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.", + tz_name, + exc, + ) + return ZoneInfo("UTC") + + +def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]: + """Convert a datetime value to the configured local timezone.""" + + if dt is None: + return None + + aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc) + return aware_dt.astimezone(get_local_timezone()) + + +def format_local_datetime( + dt: Optional[datetime], + fmt: str = "%Y-%m-%d %H:%M:%S %Z", + na_placeholder: str = "N/A", +) -> str: + """Format a datetime value in the configured local timezone.""" + + localized = to_local_datetime(dt) + if localized is None: + return na_placeholder + return localized.strftime(fmt) + + +class TimezoneAwareFormatter(logging.Formatter): + """Logging formatter that renders timestamps in the configured timezone.""" + + def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs): + super().__init__(*args, **kwargs) + if timezone_name: + try: + self._timezone = ZoneInfo(timezone_name) + except Exception as exc: # pragma: no cover - defensive branch + logger.warning( + "⚠️ Не удалось загрузить временную зону '%s': %s. Используем UTC.", + timezone_name, + exc, + ) + self._timezone = ZoneInfo("UTC") + else: + self._timezone = get_local_timezone() + + def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name + dt = datetime.fromtimestamp(record.created, tz=self._timezone) + if datefmt: + return dt.strftime(datefmt) + return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3] diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 36c3e89c..e089968d 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -58,6 +58,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.faq_service import FaqService from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService +from app.utils.timezone import format_local_datetime from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, @@ -4428,7 +4429,7 @@ async def submit_subscription_renewal_endpoint( language_code = _normalize_language_code(user) amount_label = settings.format_price(final_total) date_label = ( - subscription.end_date.strftime("%d.%m.%Y %H:%M") + format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M") if subscription.end_date else "" ) diff --git a/main.py b/main.py index d50e4689..30ec2c5d 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,7 @@ from app.services.system_settings_service import bot_configuration_service from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service from app.utils.startup_timeline import StartupTimeline +from app.utils.timezone import TimezoneAwareFormatter class GracefulExit: @@ -49,13 +50,20 @@ class GracefulExit: async def main(): + formatter = TimezoneAwareFormatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + timezone_name=settings.TIMEZONE, + ) + + file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8') + file_handler.setFormatter(formatter) + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(settings.LOG_FILE, encoding='utf-8'), - logging.StreamHandler(sys.stdout) - ] + handlers=[file_handler, stream_handler], ) logger = logging.getLogger(__name__) diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py new file mode 100644 index 00000000..81ed9104 --- /dev/null +++ b/tests/services/test_system_settings_env_priority.py @@ -0,0 +1,163 @@ +from types import SimpleNamespace +from pathlib import Path +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings +from app.services.system_settings_service import bot_configuration_service + + +@pytest.mark.asyncio +async def test_env_override_prevents_set_value(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.upsert_system_setting", + fake_upsert, + ) + + await bot_configuration_service.set_value( + object(), + "SUPPORT_USERNAME", + "db_support", + ) + + assert settings.SUPPORT_USERNAME == env_value + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_env_override_prevents_reset_value(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {"SUPPORT_USERNAME": "db"}) + + async def fake_delete(db, key): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.delete_system_setting", + fake_delete, + ) + + await bot_configuration_service.reset_value( + object(), + "SUPPORT_USERNAME", + ) + + assert settings.SUPPORT_USERNAME == env_value + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_initialize_skips_db_value_for_env_override(monkeypatch): + bot_configuration_service.initialize_definitions() + + env_value = "env_support" + monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_USERNAME"] = env_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + env_keys = set(bot_configuration_service._env_override_keys) + env_keys.add("SUPPORT_USERNAME") + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + class DummyResult: + def scalars(self): + return self + + def all(self): + return [SimpleNamespace(key="SUPPORT_USERNAME", value="db_support")] + + class DummySession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001 + return False + + async def execute(self, query): # noqa: ANN001 + return DummyResult() + + monkeypatch.setattr( + "app.services.system_settings_service.AsyncSessionLocal", + lambda: DummySession(), + ) + + async def fake_sync(): + return True + + monkeypatch.setattr( + "app.services.system_settings_service.ensure_default_web_api_token", + fake_sync, + raising=False, + ) + + await bot_configuration_service.initialize() + + assert settings.SUPPORT_USERNAME == env_value + assert "SUPPORT_USERNAME" not in bot_configuration_service._overrides_raw + assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + + +@pytest.mark.asyncio +async def test_set_value_applies_without_env_override(monkeypatch): + bot_configuration_service.initialize_definitions() + + monkeypatch.setattr(bot_configuration_service, "_env_override_keys", set()) + monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + + initial_value = True + target_value = False + + monkeypatch.setattr(settings, "SUPPORT_MENU_ENABLED", initial_value) + original_values = dict(bot_configuration_service._original_values) + original_values["SUPPORT_MENU_ENABLED"] = initial_value + monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + + async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + return None + + monkeypatch.setattr( + "app.services.system_settings_service.upsert_system_setting", + fake_upsert, + ) + + await bot_configuration_service.set_value( + object(), + "SUPPORT_MENU_ENABLED", + target_value, + ) + + assert settings.SUPPORT_MENU_ENABLED is target_value + assert bot_configuration_service.has_override("SUPPORT_MENU_ENABLED")