diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 72e21b81..c93a370c 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -269,7 +269,7 @@ async def update_ticket_settings( if request.sla_reminder_cooldown_minutes is not None: settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes if request.support_system_mode is not None: - settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower() + SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower()) # Update cabinet notification settings if request.cabinet_user_notifications_enabled is not None: diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 1df68097..b3765fa7 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -86,7 +86,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), selectinload(User.promo_group), @@ -106,7 +106,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User | result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), selectinload(User.promo_group), @@ -131,7 +131,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None: result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), selectinload(User.promo_group), @@ -152,7 +152,7 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Use result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.promo_group), selectinload(User.referrer), ) @@ -171,7 +171,7 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.promo_group), selectinload(User.referrer), ) @@ -719,7 +719,7 @@ async def get_users_list( order_by_purchase_count: bool = False, ) -> list[User]: query = select(User).options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.promo_group), selectinload(User.referrer), ) @@ -879,7 +879,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]: result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), selectinload(User.promo_group), @@ -904,7 +904,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us base_query = ( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.promo_group), selectinload(User.referrer), ) @@ -966,7 +966,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]: result = await db.execute( select(User) .options( - selectinload(User.subscription), + selectinload(User.subscription).selectinload(Subscription.tariff), selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), selectinload(User.referrer), selectinload(User.promo_group), @@ -1050,7 +1050,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]: Subscription.end_date > current_time, ) ) - .options(selectinload(User.subscription)) + .options(selectinload(User.subscription).selectinload(Subscription.tariff)) ) return result.scalars().unique().all() diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index ead1e744..c24eae45 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -3628,9 +3628,8 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User, await callback.answer() logger.info( - 'Админ модем для пользователя', telegram_id=db_user.telegram_id, action_text=action_text, user_id=user_id + 'Админ изменил устройства для пользователя', telegram_id=db_user.telegram_id, devices=devices, user_id=user_id ) - await callback.answer() @admin_required diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index c3dea8d1..5793730d 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1547,7 +1547,7 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use # Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume from app.database.crud.tariff import get_tariff_by_id - tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id) + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff and getattr(tariff, 'is_daily', False): # Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить») await show_subscription_info(callback, db_user, db) diff --git a/app/logging_config.py b/app/logging_config.py index 0bd21c5b..b1308c98 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -48,6 +48,14 @@ def _clean_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any] return event_dict +def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: + """Move logger name before event text: [module.name] event text.""" + logger_name = event_dict.pop('logger', None) + if logger_name: + event_dict['event'] = f'[{logger_name}] {event_dict.get("event", "")}' + return event_dict + + def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: """Configure structlog and return formatters + notifier. @@ -103,6 +111,7 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, + _prefix_logger_name, structlog.dev.ConsoleRenderer( colors=False, pad_event_to=0, @@ -118,6 +127,7 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, + _prefix_logger_name, structlog.dev.ConsoleRenderer( pad_event_to=0, pad_level=False, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 379a7878..4b399521 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -57,7 +57,6 @@ from app.services.notification_delivery_service import ( notification_delivery_service, ) from app.services.notification_settings_service import NotificationSettingsService -from app.services.payment_service import PaymentService from app.services.promo_offer_service import promo_offer_service from app.services.subscription_service import SubscriptionService from app.utils.cache import cache @@ -83,7 +82,6 @@ class MonitoringService: def __init__(self, bot=None): self.is_running = False self.subscription_service = SubscriptionService() - self.payment_service = PaymentService() self.bot = bot self._notified_users: set[str] = set() self._last_cleanup = datetime.utcnow() diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 774cb0bf..b7b3d054 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -62,6 +62,7 @@ class SupportSettingsService: return False cls._load() cls._data['system_mode'] = mode_clean + settings.SUPPORT_SYSTEM_MODE = mode_clean return cls._save() # Main menu visibility diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 3e6a6164..1547f405 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -1603,6 +1603,13 @@ class BotConfigurationService: ) except Exception as error: logger.error('Не удалось обновить сервис автосинхронизации RemnaWave', error=error) + elif key == 'SUPPORT_SYSTEM_MODE': + try: + from app.services.support_settings_service import SupportSettingsService + + SupportSettingsService.set_system_mode(str(value)) + except Exception as error: + logger.error('Не удалось синхронизировать SupportSettingsService', error=error) elif key in { 'REMNAWAVE_API_URL', 'REMNAWAVE_API_KEY', diff --git a/app/utils/startup_timeline.py b/app/utils/startup_timeline.py index feb548e5..c289303b 100644 --- a/app/utils/startup_timeline.py +++ b/app/utils/startup_timeline.py @@ -1,11 +1,58 @@ import platform import time +import unicodedata from collections.abc import Iterable, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any +def _char_width(ch: str) -> int: + """Return terminal display width of a single character.""" + cp = ord(ch) + # Variation selector U+FE0F / U+FE0E — zero width (handled by caller) + if cp in (0xFE0E, 0xFE0F, 0x200D): + return 0 + # Combining marks — zero width + if unicodedata.category(ch).startswith('M'): + return 0 + # East Asian Wide / Fullwidth + if unicodedata.east_asian_width(ch) in ('W', 'F'): + return 2 + return 1 + + +def _display_width(text: str) -> int: + """Calculate terminal display width accounting for wide chars and emoji.""" + width = 0 + prev_base = 0 + for ch in text: + cp = ord(ch) + # U+FE0F emoji presentation selector — upgrades previous char to 2 cells + if cp == 0xFE0F: + if prev_base == 1: + width += 1 # upgrade 1 → 2 + prev_base = 2 + continue + cw = _char_width(ch) + if cw > 0: + prev_base = cw + width += cw + return width + + +def _ljust(text: str, width: int) -> str: + """Left-justify text to given display width.""" + return text + ' ' * max(0, width - _display_width(text)) + + +def _center(text: str, width: int) -> str: + """Center text to given display width.""" + pad = max(0, width - _display_width(text)) + left = pad // 2 + return ' ' * left + text + ' ' * (pad - left) + + @dataclass class StepRecord: title: str @@ -85,25 +132,25 @@ class StartupTimeline: subtitle_parts.append(f'{key}: {value}') subtitle_text = ' | '.join(subtitle_parts) - width = max(len(title_text), len(subtitle_text)) + width = max(_display_width(title_text), _display_width(subtitle_text)) border = '╔' + '═' * (width + 2) + '╗' self.logger.info(border) - self.logger.info('║ ' + title_text.ljust(width) + ' ║') - self.logger.info('║ ' + subtitle_text.ljust(width) + ' ║') + self.logger.info('║ ' + _ljust(title_text, width) + ' ║') + self.logger.info('║ ' + _ljust(subtitle_text, width) + ' ║') self.logger.info('╚' + '═' * (width + 2) + '╝') def log_section(self, title: str, lines: Iterable[str], icon: str = '📄') -> None: items = [f'{icon} {title}'] + [f'• {line}' for line in lines] - width = max(len(item) for item in items) + width = max(_display_width(item) for item in items) top = '┌ ' + '─' * width + ' ┐' middle = '├ ' + '─' * width + ' ┤' bottom = '└ ' + '─' * width + ' ┘' self.logger.info(top) - self.logger.info('│ ' + items[0].ljust(width) + ' │') + self.logger.info('│ ' + _ljust(items[0], width) + ' │') self.logger.info(middle) for item in items[1:]: - self.logger.info('│ ' + item.ljust(width) + ' │') + self.logger.info('│ ' + _ljust(item, width) + ' │') self.logger.info(bottom) def add_manual_step( @@ -169,15 +216,15 @@ class StartupTimeline: base += f' :: {step.message}' lines.append(base) - width = max(len(line) for line in lines) + width = max(_display_width(line) for line in lines) border_top = '┏' + '━' * (width + 2) + '┓' border_mid = '┣' + '━' * (width + 2) + '┫' border_bottom = '┗' + '━' * (width + 2) + '┛' title = 'РЕЗЮМЕ ЗАПУСКА' self.logger.info(border_top) - self.logger.info('┃ ' + title.center(width) + ' ┃') + self.logger.info('┃ ' + _center(title, width) + ' ┃') self.logger.info(border_mid) for line in lines: - self.logger.info('┃ ' + line.ljust(width) + ' ┃') + self.logger.info('┃ ' + _ljust(line, width) + ' ┃') self.logger.info(border_bottom) diff --git a/main.py b/main.py index 0d9cfdd3..00714c44 100644 --- a/main.py +++ b/main.py @@ -119,6 +119,7 @@ async def main(): logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL), handlers=log_handlers, + force=True, ) # Регистрируем хэндлеры для управления при ротации @@ -137,6 +138,7 @@ async def main(): logging.basicConfig( level=getattr(logging, settings.LOG_LEVEL), handlers=log_handlers, + force=True, ) # NOTE: TelegramNotifierProcessor and noisy logger suppression are @@ -684,8 +686,16 @@ async def main(): webhook_lines.append(f'WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}') if settings.is_heleket_enabled(): webhook_lines.append(f'Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}') + if settings.is_platega_enabled(): + webhook_lines.append(f'Platega: {_fmt(settings.PLATEGA_WEBHOOK_PATH)}') + if settings.is_cloudpayments_enabled(): + webhook_lines.append(f'CloudPayments: {_fmt(settings.CLOUDPAYMENTS_WEBHOOK_PATH)}') if settings.is_freekassa_enabled(): webhook_lines.append(f'Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}') + if settings.is_kassa_ai_enabled(): + webhook_lines.append(f'Kassa.ai: {_fmt(settings.KASSA_AI_WEBHOOK_PATH)}') + if settings.is_remnawave_webhook_enabled(): + webhook_lines.append(f'RemnaWave: {_fmt(settings.REMNAWAVE_WEBHOOK_PATH)}') timeline.log_section( 'Активные webhook endpoints',