From a93a32f3a7d1b259a2e24954ae5d2b7c966c5639 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 17:54:43 +0300 Subject: [PATCH 1/8] fix: resolve MissingGreenlet error when accessing subscription.tariff Add .selectinload(Subscription.tariff) chain to all User queries that load subscriptions, preventing lazy loading of the tariff relationship in async context. Also replace unsafe getattr(subscription, 'tariff') with explicit async get_tariff_by_id() in handle_extend_subscription. --- app/database/crud/user.py | 20 ++++++++++---------- app/handlers/subscription/purchase.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) 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/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) From 0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 18:22:44 +0300 Subject: [PATCH 2/8] fix: sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings When changing SUPPORT_SYSTEM_MODE via system settings admin panel, the SupportSettingsService JSON cache was not updated, causing the old value to take priority. Now both services stay in sync bidirectionally. --- app/services/support_settings_service.py | 1 + app/services/system_settings_service.py | 7 +++++++ 2 files changed, 8 insertions(+) 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', From 516be6e600a08ad700d83b793dc64b2ca07bdf44 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 18:24:27 +0300 Subject: [PATCH 3/8] fix: sync support mode from cabinet admin to SupportSettingsService Cabinet admin endpoint was setting settings.SUPPORT_SYSTEM_MODE directly without updating SupportSettingsService JSON, causing bot to show stale mode. Now routes through set_system_mode() which updates both stores. --- app/cabinet/routes/admin_tickets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From f63720467a935bdaaa58bb34d588d65e46698f26 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 18:33:40 +0300 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20improve=20log=20formatting=20?= =?UTF-8?q?=E2=80=94=20logger=20name=20prefix=20and=20table=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add _prefix_logger_name processor that moves [module.name] before event text for consistent format: timestamp [level] [module] message 2. Fix startup summary table alignment by using display width calculation instead of len() — properly accounts for wide emoji and variation selectors that render as 2 terminal cells --- app/logging_config.py | 10 ++++++ app/utils/startup_timeline.py | 65 ++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 9 deletions(-) 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/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) From 7eb8d4e153bab640a5829f75bfa6f70df5763284 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 18:49:39 +0300 Subject: [PATCH 5/8] fix: force basicConfig to replace pre-existing handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logging.basicConfig() silently does nothing if the root logger already has handlers. When import-time side effects trigger stdlib logging before main() configures formatters, our ProcessorFormatter with pad_level=False never gets applied — producing [debug ] instead of [debug]. --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index 0d9cfdd3..d7ea19ef 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 From 491a7e1c425a355e55b3020e2bcc7b96047bdf5e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 19:02:57 +0300 Subject: [PATCH 6/8] fix: remove unused PaymentService from MonitoringService init MonitoringService instantiated PaymentService() at module level during import, triggering a debug log before structlog/logging were configured. This caused [debug ] with padded spaces (structlog default pad_level) and appeared 7 seconds before the startup banner. The payment_service attribute was never used in MonitoringService. --- app/services/monitoring_service.py | 2 -- 1 file changed, 2 deletions(-) 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() From 9d710050ad40ba76a14aa6ace8e8a47f25cdde94 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 19:08:49 +0300 Subject: [PATCH 7/8] feat: show all active webhook endpoints in startup log Added missing webhook endpoints to the startup section: Platega, CloudPayments, Kassa.ai, and RemnaWave webhook. --- main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/main.py b/main.py index d7ea19ef..00714c44 100644 --- a/main.py +++ b/main.py @@ -686,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', From 1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 16 Feb 2026 19:09:52 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20NameError=20in=20set=5Fuser=5Fdevice?= =?UTF-8?q?s=5Fbutton=20=E2=80=94=20undefined=20action=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced undefined action_text with devices (the actual value being set). Removed duplicate await callback.answer() call. --- app/handlers/admin/users.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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