Compare commits

..

34 Commits

Author SHA1 Message Date
dependabot[bot] e375252d65 ci(deps): bump the github-actions group across 1 directory with 3 updates
Bumps the github-actions group with 3 updates in the / directory: [actions/checkout](https://github.com/actions/checkout), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `astral-sh/setup-uv` from 5 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7)

Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 07:12:26 +00:00
Egor fa78fa6d09 Merge pull request #2609 from BEDOLAGA-DEV/dev
Dev
2026-02-16 10:10:52 +03:00
Fringg 11f8af003f fix: resolve exc_info for admin notifications, clean log formatting
- TelegramNotifierProcessor: resolve exc_info=True → sys.exc_info()
  tuple while still in except block, fixing "(no traceback available)"
- Use real exception type (e.g. TelegramBadRequest) instead of LogError
- Include user_id/username in admin notification context
- ConsoleRenderer: pad_level=False removes trailing spaces in [info]
- Strip [__main__] logger name from startup/timeline logs
2026-02-16 10:06:37 +03:00
Fringg 11ef714e0d fix: limit Rich traceback output to prevent console flood
RichTracebackFormatter defaults (show_locals=True, max_frames=100)
produced 5000+ line tracebacks on chained exceptions with aiogram.
Now: show_locals=False, max_frames=20, suppress aiogram/aiohttp frames.
2026-02-16 09:57:46 +03:00
Fringg 909a4039c4 fix: traceback in Telegram notifications + reduce log padding
- LoggingMiddleware: logger.error → logger.exception to include exc_info
  so TelegramNotifierProcessor can extract traceback for admin chat
- ConsoleRenderer: pad_event_to=0 to remove excessive whitespace
  in short event names (timeline markers like ┃, ┗)
2026-02-16 09:55:56 +03:00
Fringg bf646112df feat: colored console logs via structlog + rich + FORCE_COLOR
- Add rich dependency for colored tracebacks and console rendering
- Set FORCE_COLOR=1 in docker-compose for color output in containers
- Remove format_exc_info from processor chain — ConsoleRenderer now
  handles exc_info directly (Rich tracebacks on console, plain in files)
- Let ConsoleRenderer auto-detect colors via FORCE_COLOR env var
2026-02-16 09:43:41 +03:00
Fringg 8a6650e57c fix: suppress startup log noise (~350 lines → ~30)
- Suppress migration logger to WARNING during startup (main.py)
- Remove debug logs from get_traffic_packages() leaking before structlog init
- Downgrade handler registration logs to debug (start.py)
- Remove duplicate section headers from migration orchestrator
2026-02-16 09:34:17 +03:00
Fringg 25e8c9f8fc fix: use sync context manager for structlog bound_contextvars
bound_contextvars() returns a sync _GeneratorContextManager, not async.
Using `async with` caused TypeError crashing all web API requests.
2026-02-16 09:23:22 +03:00
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00
Egor be6036e879 Merge pull request #2607 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.1
2026-02-16 07:32:42 +03:00
github-actions[bot] bba85a309a chore(main): release 3.12.1 2026-02-16 04:32:16 +00:00
Egor 448be6e512 Merge pull request #2606 from BEDOLAGA-DEV/dev
Dev
2026-02-16 07:31:48 +03:00
Fringg 871ceb866c fix: replace deprecated Query(regex=) with pattern= 2026-02-16 07:11:58 +03:00
Fringg 8e61fe4774 fix: handle TelegramBadRequest in ticket edit_message_text calls
Wrap all edit_message_text calls in ticket handlers with try/except
TelegramBadRequest fallback to message.answer(). Fixes crash when
the prompt message was deleted or has no text (e.g. photo message).
2026-02-16 07:09:06 +03:00
Fringg d4dfa235e5 chore: update all dependencies to latest stable versions
Security: cryptography 41.0→44.0+ (4 CVEs patched)
Major: redis 5.0→7.1, fastapi 0.115→0.129, bcrypt 4.2→5.0
Minor: sqlalchemy 2.0.46, alembic 1.18.4, asyncpg 0.31,
  aiosqlite 0.22, qrcode 8.0, packaging 26.0, pyjwt 2.11,
  yookassa 3.10, pyyaml 6.0.3
2026-02-16 06:59:48 +03:00
Fringg 97ec39aa80 fix: add promo code anti-abuse protections
- Rate-limit on brute-force: 5 failed attempts per 5 min blocks user
- Daily stacking limit: max 5 promo activations per 24h (in-memory + DB)
- Format validation: only alphanumeric/hyphen/underscore, 3-50 chars
2026-02-16 06:52:45 +03:00
Fringg 61a97220d3 fix: add /start burst rate-limit to prevent spam abuse
Sliding window limiter: max 3 /start calls per 60 seconds per user.
Runs before the general 0.5s throttle. Shows cooldown timer on block.
Lazy cleanup of start_buckets when size exceeds 500 entries.
2026-02-16 06:41:14 +03:00
Egor 2d04f2aa28 Merge pull request #2605 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.0
2026-02-16 02:20:45 +03:00
github-actions[bot] d6e79161e7 chore(main): release 3.12.0 2026-02-15 23:20:25 +00:00
Egor 45fd543206 Merge pull request #2604 from BEDOLAGA-DEV/dev
Dev
2026-02-16 02:20:02 +03:00
Fringg ba0a5e9abd fix: handle tariff_extend callback without period (back button crash)
The 'Back' button on tariff extend confirmation sends
tariff_extend:{id} without a period segment, which crashed
select_tariff_extend_period with IndexError on parts[2].
Now redirects to show_tariff_extend when period is missing.
2026-02-16 01:38:04 +03:00
Fringg d712ab8301 fix: remove redundant trial inactivity monitoring checks
Remnawave already sends user.not_connected webhooks, making the
monitoring service's 1h/24h trial inactivity checks redundant.
The monitoring checks caused false positives because they relied on
traffic_used_gb which may not be synced in real-time.

Removed:
- _check_trial_inactivity_notifications from monitoring cycle
- _send_trial_inactive_notification method
- trial_inactive_1h / trial_inactive_24h notification settings
- Admin UI toggles and preview buttons for these notifications
2026-02-16 00:58:24 +03:00
Fringg 1e2a7e3096 fix: webhook notification 'My Subscription' button uses unregistered callback_data
Changed callback_data from 'subscription' (no handler) to 'menu_subscription'
(registered handler) in _get_subscription_keyboard and _get_traffic_keyboard.
In cabinet mode the button opens a WebApp URL so the bug was invisible,
but in default MAIN_MENU_MODE the callback went unhandled.
2026-02-16 00:30:17 +03:00
Fringg 64a684cd2f fix: filter out traffic packages with zero price from purchase options 2026-02-15 23:32:15 +03:00
Fringg e4c207ecff chore: format files with ruff 2026-02-15 23:18:44 +03:00
Fringg 80914c1af7 fix: daily tariff subscriptions stuck in expired/disabled with no resume path
- Keyboard now shows "Возобновить" for disabled/expired daily tariffs
  instead of useless "Приостановить"
- resume_daily_subscription handles EXPIRED→ACTIVE (not only DISABLED)
- Pause handler detects inactive status and calls resume directly
- subscription_extend redirects daily tariffs to subscription info
  (daily tariffs have no period_prices, so extend page was empty)
2026-02-15 23:17:45 +03:00
Fringg e1822800ab fix: handle photo message in ticket creation flow
Ticket creation crashed with "there is no text in the message to edit"
when initiated from the tickets list (rendered as photo with logo).
2026-02-15 22:58:31 +03:00
Fringg 68773b7e77 feat: add per-button enable/disable toggle and custom labels per locale
- Add enabled flag to hide/show each button section in main menu
- Add per-locale custom labels (ru, en, ua, zh, fa) for button text
- Deep-copy nested labels dict in cache to prevent reference leaks
- Validate label entries from DB (type + locale key checks)
- Use selective merge in PATCH handler instead of blind .update()
2026-02-12 23:42:55 +03:00
Fringg 10538e7351 feat: add 'default' (no color) option for button styles
Allow admins to set buttons to Telegram's default style with no color
override. Refactors style resolution from or-chain to explicit if/elif/else
so that 'default' does not fall through to global config or hardcoded defaults.
2026-02-12 23:25:42 +03:00
Fringg a9687912df feat: add per-section button style and emoji customization via admin API
Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.

Style resolution chain: explicit param > per-section DB > global config > defaults.
2026-02-12 23:15:58 +03:00
Fringg 46c1a69456 fix: pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults 2026-02-12 22:43:30 +03:00
Fringg bf2b2f1c56 feat: add button style and emoji support for cabinet mode (Bot API 9.4)
- Upgrade aiogram to 3.25.0 for style/icon_custom_emoji_id support
- Add CABINET_BUTTON_STYLE config for global color override
- Per-section default styles: subscription (green), balance (blue),
  referral (green), admin (red), home (blue)
- Style priority: explicit > CABINET_BUTTON_STYLE > per-section default
- Add icon_custom_emoji_id pass-through for Premium bot owners
- Admin panel setting for button style with color picker
2026-02-12 22:34:38 +03:00
Fringg 9ac6da490d feat: add web admin button for admins in cabinet mode 2026-02-12 22:22:28 +03:00
Fringg ad87c5fb5e feat: rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections
- Rename mode from 'text' to 'cabinet' (text/text_only/minimal kept as aliases)
- Add build_cabinet_url() for joining MINIAPP_CUSTOM_URL with section paths
- Cabinet main menu now has section-specific buttons: subscription, balance,
  referral, support, info — each opens the corresponding cabinet page
- Add CALLBACK_TO_CABINET_PATH mapping for automatic deep-linking from
  callback_data to cabinet routes (/subscription, /balance, /referral, etc.)
- Unmapped callback_data gracefully falls back to regular Telegram callbacks
- Add startup validation warning when cabinet mode is active without MINIAPP_CUSTOM_URL
- Update admin broadcast buttons with section-specific routing
- Backward compatible: is_text_main_menu_mode() kept as alias for is_cabinet_mode()
2026-02-12 22:21:08 +03:00
308 changed files with 8383 additions and 7226 deletions
+12 -1
View File
@@ -697,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
+3 -3
View File
@@ -10,11 +10,11 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v5
- uses: astral-sh/setup-uv@v7
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.13'
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.11.0"
".": "3.12.1"
}
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
### Bug Fixes
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
### New Features
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
### Bug Fixes
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.11.0" # x-release-please-version
ARG VERSION="v3.12.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+31 -11
View File
@@ -1,6 +1,5 @@
import logging
import redis.asyncio as redis
import structlog
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
@@ -63,6 +62,7 @@ from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.context_binding import ContextVarsMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
@@ -75,14 +75,14 @@ from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info('🔍 DEBUG CALLBACK:')
logger.info(f' - Data: {callback.data}')
logger.info(f' - User: {callback.from_user.id}')
logger.info(f' - Username: {callback.from_user.username}')
logger.info('Data', callback_data=callback.data)
logger.info('User', from_user_id=callback.from_user.id)
logger.info('Username', username=callback.from_user.username)
async def setup_bot() -> tuple[Bot, Dispatcher]:
@@ -90,7 +90,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
await cache.connect()
logger.info('Кеш инициализирован')
except Exception as e:
logger.warning(f'Кеш не инициализирован: {e}')
logger.warning('Кеш не инициализирован', error=e)
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -106,12 +106,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
storage = RedisStorage(redis_client)
logger.info('Подключено к Redis для FSM storage')
except Exception as e:
logger.warning(f'Не удалось подключиться к Redis: {e}')
logger.warning('Не удалось подключиться к Redis', error=e)
logger.info('Используется MemoryStorage для FSM')
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(ContextVarsMiddleware())
dp.callback_query.middleware(ContextVarsMiddleware())
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
@@ -205,7 +208,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
await maintenance_service.start_monitoring()
logger.info('Мониторинг техработ запущен')
except Exception as e:
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
logger.error('Ошибка запуска мониторинга техработ', error=e)
else:
logger.info('Мониторинг техработ отключен настройками')
@@ -229,6 +232,23 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
'Кнопка "Подключиться" не будет работать.'
)
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
'Кнопки кабинета не смогут открывать разделы MiniApp. '
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning('Failed to load button styles cache', error=e)
logger.info('Бот успешно настроен')
@@ -240,10 +260,10 @@ async def shutdown_bot():
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
except Exception as e:
logger.error(f'Ошибка остановки мониторинга: {e}')
logger.error('Ошибка остановки мониторинга', error=e)
try:
await cache.close()
logger.info('Соединения с кешем закрыты')
except Exception as e:
logger.error(f'Ошибка закрытия кеша: {e}')
logger.error('Ошибка закрытия кеша', error=e)
+2 -2
View File
@@ -1,18 +1,18 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import logging
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
import structlog
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
STATE_TTL_SECONDS = 600 # 10 minutes
+6 -4
View File
@@ -1,8 +1,8 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import logging
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -18,7 +18,7 @@ from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
@@ -161,10 +161,12 @@ async def get_current_cabinet_user(
except HTTPException:
raise
except TimeoutError:
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
)
# Don't block user if check fails
return user
+2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
@@ -91,6 +92,7 @@ router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)
+19 -12
View File
@@ -1,9 +1,9 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
import logging
from pathlib import Path
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +16,7 @@ from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
@@ -231,7 +231,7 @@ async def create_app(
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@@ -274,7 +274,7 @@ async def update_app(
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@@ -310,7 +310,7 @@ async def delete_app(
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {'status': 'deleted', 'app_id': app_id}
@@ -355,7 +355,7 @@ async def reorder_apps(
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {'status': 'reordered', 'order': request.app_ids}
@@ -374,7 +374,7 @@ async def update_branding(
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info(f'Admin {admin.id} updated branding')
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@@ -434,7 +434,14 @@ async def copy_app_to_platform(
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
@@ -498,9 +505,9 @@ async def set_remnawave_config_uuid(
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error(f'Error saving RemnaWave config UUID: {e}')
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save configuration',
@@ -547,7 +554,7 @@ async def get_remnawave_subscription_config(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error fetching RemnaWave config: {e}')
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
@@ -572,7 +579,7 @@ async def list_remnawave_subscription_configs(
for c in configs
]
except Exception as e:
logger.error(f'Error listing RemnaWave configs: {e}')
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
+15 -13
View File
@@ -1,8 +1,8 @@
"""Admin routes for Ban System monitoring in cabinet."""
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.config import settings
@@ -45,7 +45,7 @@ from ..schemas.ban_system import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System'])
@@ -53,9 +53,11 @@ router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']
def _get_ban_api() -> BanSystemAPI:
"""Get Ban System API instance."""
logger.debug(
f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}'
'Ban System check enabled: configured',
is_ban_system_enabled=settings.is_ban_system_enabled(),
is_ban_system_configured=settings.is_ban_system_configured(),
)
logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}')
logger.debug('Ban System URL', get_ban_system_api_url=settings.get_ban_system_api_url())
if not settings.is_ban_system_enabled():
raise HTTPException(
@@ -83,13 +85,13 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
func = getattr(api, method)
return await func(*args, **kwargs)
except BanSystemAPIError as e:
logger.error(f'Ban System API error: {e}')
logger.error('Ban System API error', error=e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f'Ban System API error: {e.message}',
)
except Exception as e:
logger.error(f'Ban System unexpected error: {e}')
logger.error('Ban System unexpected error', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Internal error: {e!s}',
@@ -133,7 +135,7 @@ async def get_stats(
api = _get_ban_api()
data = await _api_request(api, 'get_stats')
logger.debug(f'Ban System raw stats: {data}')
logger.debug('Ban System raw stats', data=data)
# Extract punishment stats
punishment_stats = data.get('punishment_stats') or {}
@@ -364,7 +366,7 @@ async def unban_user(
api = _get_ban_api()
try:
await _api_request(api, 'enable_user', user_id=user_id)
logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System')
logger.info('Admin unbanned user in Ban System', admin_id=admin.id, user_id=user_id)
return UnbanResponse(success=True, message='User unbanned successfully')
except HTTPException:
raise
@@ -387,7 +389,7 @@ async def ban_user(
minutes=request.minutes,
reason=request.reason,
)
logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}')
logger.info('Admin banned user', admin_id=admin.id, username=request.username, reason=request.reason)
return UnbanResponse(success=True, message='User banned successfully')
except HTTPException:
raise
@@ -819,7 +821,7 @@ async def set_setting(
api = _get_ban_api()
data = await _api_request(api, 'set_setting', key=key, value=value)
logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}')
logger.info('Admin changed Ban System setting to', admin_id=admin.id, key=key, value=value)
return _parse_setting_response(key, data)
@@ -833,7 +835,7 @@ async def toggle_setting(
api = _get_ban_api()
data = await _api_request(api, 'toggle_setting', key=key)
logger.info(f'Admin {admin.id} toggled Ban System setting {key}')
logger.info('Admin toggled Ban System setting', admin_id=admin.id, key=key)
return _parse_setting_response(key, data, default_type='bool')
@@ -850,7 +852,7 @@ async def whitelist_add(
api = _get_ban_api()
try:
await _api_request(api, 'whitelist_add', username=request.username)
logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist')
logger.info('Admin added to Ban System whitelist', admin_id=admin.id, username=request.username)
return UnbanResponse(success=True, message=f'User {request.username} added to whitelist')
except HTTPException:
raise
@@ -867,7 +869,7 @@ async def whitelist_remove(
api = _get_ban_api()
try:
await _api_request(api, 'whitelist_remove', username=request.username)
logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist')
logger.info('Admin removed from Ban System whitelist', admin_id=admin.id, username=request.username)
return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist')
except HTTPException:
raise
+18 -10
View File
@@ -1,8 +1,8 @@
"""Admin routes for broadcasts in cabinet."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,7 +40,7 @@ from ..schemas.broadcasts import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
@@ -255,7 +255,7 @@ async def get_filters(
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for filter {key}: {e}')
logger.warning('Failed to get count for filter', key=key, error=e)
count = 0
filters.append(
BroadcastFilter(
@@ -272,7 +272,7 @@ async def get_filters(
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for custom filter {key}: {e}')
logger.warning('Failed to get count for custom filter', key=key, error=e)
count = 0
custom_filters.append(
BroadcastFilter(
@@ -367,7 +367,7 @@ async def preview_broadcast(
try:
count = await get_target_users_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get count for target {request.target}: {e}')
logger.error('Failed to get count for target', target=request.target, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count recipients',
@@ -450,7 +450,9 @@ async def create_broadcast(
await broadcast_service.start_broadcast(broadcast.id, config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
logger.info(
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
)
return _serialize_broadcast(broadcast)
@@ -494,7 +496,7 @@ async def get_email_filters(
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
logger.warning('Failed to get count for email filter', key=key, error=e)
count = 0
filters.append(
@@ -532,7 +534,7 @@ async def preview_email_broadcast(
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
logger.error('Failed to get email count for target', target=request.target, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
@@ -661,7 +663,13 @@ async def create_combined_broadcast(
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
logger.info(
'Admin created broadcast for target',
admin_id=admin.id,
channel=request.channel,
broadcast_id=broadcast.id,
target=request.target,
)
return _serialize_broadcast(broadcast)
@@ -721,6 +729,6 @@ async def stop_broadcast(
await db.commit()
await db.refresh(broadcast)
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
return _serialize_broadcast(broadcast)
+255
View File
@@ -0,0 +1,255 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info(
'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections
)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+6 -7
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing advertising campaigns in cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,7 +44,7 @@ from ..schemas.campaigns import (
from ..schemas.tariffs import TariffListItem
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
@@ -378,7 +377,7 @@ async def create_new_campaign(
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
return await get_campaign(campaign.id, admin, db)
@@ -447,7 +446,7 @@ async def update_existing_campaign(
if updates:
await update_campaign(db, campaign, **updates)
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
return await get_campaign(campaign_id, admin, db)
@@ -475,7 +474,7 @@ async def delete_existing_campaign(
)
await delete_campaign(db, campaign)
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
return {'message': 'Campaign deleted successfully'}
@@ -498,7 +497,7 @@ async def toggle_campaign(
await update_campaign(db, campaign, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
return CampaignToggleResponse(
id=campaign_id,
+13 -16
View File
@@ -1,9 +1,9 @@
"""Admin routes for managing email notification templates."""
import asyncio
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from ..services.email_template_overrides import (
from ..services.email_templates import EmailNotificationTemplates
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
@@ -505,10 +505,7 @@ async def update_template(
)
logger.info(
'Админ %s обновил email шаблон %s/%s',
admin.id,
notification_type,
language,
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=language
)
return {'status': 'ok', 'template': result}
@@ -533,10 +530,10 @@ async def reset_template(
if deleted:
logger.info(
'Админ %s сбросил email шаблон %s/%s к дефолту',
admin.id,
notification_type,
language,
'Админ сбросил email шаблон / к дефолту',
admin_id=admin.id,
notification_type=notification_type,
language=language,
)
return {'status': 'ok', 'was_custom': deleted}
@@ -657,7 +654,7 @@ async def send_test_email(
body_html=body_html,
)
except Exception as e:
logger.error('Ошибка отправки тестового email: %s', e)
logger.error('Ошибка отправки тестового email', e=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to send test email: {e!s}',
@@ -670,11 +667,11 @@ async def send_test_email(
)
logger.info(
'Админ %s отправил тестовый email %s/%s на %s',
admin.id,
notification_type,
language,
to_email,
'Админ отправил тестовый email / на',
admin_id=admin.id,
notification_type=notification_type,
language=language,
to_email=to_email,
)
return {'status': 'ok', 'sent_to': to_email}
+4 -4
View File
@@ -1,8 +1,8 @@
"""Admin routes for payment method configuration in cabinet."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,7 +20,7 @@ from app.services.payment_method_config_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
@@ -168,7 +168,7 @@ async def update_payment_methods_order(
):
"""Batch update sort order for payment methods."""
await update_sort_order(db, request.method_ids)
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids)
return {'success': True}
@@ -222,7 +222,7 @@ async def update_payment_method(
detail=f'Payment method not found: {method_id}',
)
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
logger.info('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
+10 -3
View File
@@ -1,9 +1,9 @@
"""Admin routes for payment verification in cabinet."""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,7 +22,7 @@ from app.services.payment_verification_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
@@ -406,7 +406,14 @@ async def check_payment_status(
if status_changed:
_, new_status_text = _get_status_info(updated)
message = f'Статус обновлён: {new_status_text}'
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
logger.info(
'Admin checked payment /',
admin_id=admin.id,
method=method,
payment_id=payment_id,
old_status=old_status,
status=updated.status,
)
else:
message = 'Статус не изменился'
+24 -9
View File
@@ -1,9 +1,9 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -34,7 +34,7 @@ from ..schemas.pinned_messages import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
@@ -186,7 +186,9 @@ async def create_pinned_message(
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
logger.info(
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -229,7 +231,7 @@ async def update_pinned_message(
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
return _serialize_pinned_message(msg)
@@ -273,7 +275,7 @@ async def deactivate_active_message(
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id)
return _serialize_pinned_message(msg)
@@ -288,7 +290,12 @@ async def unpin_active_message(
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
logger.info(
'Admin unpinned active message: unpinned=, failed',
admin_id=admin.id,
unpinned_count=unpinned_count,
failed_count=failed_count,
)
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
@@ -339,7 +346,9 @@ async def activate_pinned_message(
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
logger.info(
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -364,7 +373,13 @@ async def broadcast_message(
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
logger.info(
'Admin broadcast pinned message #: sent=, failed',
admin_id=admin.id,
message_id=message_id,
sent_count=sent_count,
failed_count=failed_count,
)
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
@@ -394,4 +409,4 @@ async def delete_pinned_message(
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
+5 -13
View File
@@ -3,10 +3,10 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -37,7 +37,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
@@ -430,7 +430,7 @@ async def _send_promo_notifications(
async def send_single(user: User, offer: DiscountOffer) -> bool:
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug(f'Skipping promo notification for email-only user {user.id}')
logger.debug('Skipping promo notification for email-only user', user_id=user.id)
return False
async with semaphore:
@@ -459,18 +459,10 @@ async def _send_promo_notifications(
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
'Failed to send promo notification to user %s: %s',
user.telegram_id,
exc,
)
logger.warning('Failed to send promo notification to user', telegram_id=user.telegram_id, exc=exc)
return False
except Exception as exc:
logger.error(
'Error sending promo notification to user %s: %s',
user.telegram_id,
exc,
)
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=exc)
return False
# Send in batches
+31 -19
View File
@@ -1,9 +1,9 @@
"""Admin routes for RemnaWave management in cabinet."""
import logging
from datetime import datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -76,7 +76,7 @@ except Exception:
remnawave_sync_service = None
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
@@ -380,7 +380,9 @@ async def perform_node_action(
}
if success:
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
logger.info(
'Admin performed on node', telegram_id=admin.telegram_id, action=payload.action, node_uuid=node_uuid
)
return NodeActionResponse(
success=True,
message=messages.get(payload.action, 'Action completed'),
@@ -403,7 +405,7 @@ async def restart_all_nodes(
success = await service.restart_all_nodes()
if success:
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
return NodeActionResponse(success=True, message='All nodes restart initiated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -510,7 +512,9 @@ async def create_squad(
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
if squad_uuid:
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
logger.info(
'Admin created squad', telegram_id=admin.telegram_id, payload_name=payload.name, squad_uuid=squad_uuid
)
return SquadOperationResponse(
success=True,
message='Squad created successfully',
@@ -545,7 +549,7 @@ async def update_squad(
)
if success:
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
logger.info('Admin updated squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
return SquadOperationResponse(success=True, message='Squad updated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -594,7 +598,7 @@ async def perform_squad_action(
message = 'Inbounds updated' if success else 'Failed to update inbounds'
if success:
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
logger.info('Admin performed on squad', telegram_id=admin.telegram_id, action=action, squad_uuid=squad_uuid)
return SquadOperationResponse(success=success, message=message)
@@ -611,7 +615,7 @@ async def delete_squad(
success = await service.delete_squad(squad_uuid)
if success:
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
logger.info('Admin deleted squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
return SquadOperationResponse(success=True, message='Squad deleted')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -699,7 +703,9 @@ async def migrate_squad_users(
error=result.get('error'),
)
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
logger.info(
'Admin migrated users from to', telegram_id=admin.telegram_id, source_uuid=source_uuid, target_uuid=target_uuid
)
return MigrationResponse(
success=True,
@@ -782,14 +788,14 @@ async def toggle_auto_sync(
if payload.enabled and not current_status.enabled:
# Enable - would need to update settings and refresh schedule
remnawave_sync_service.schedule_refresh(run_immediately=True)
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
logger.info('Admin enabled auto sync', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
message='Auto sync enabled and scheduled',
)
if not payload.enabled and current_status.enabled:
# Disable - would need to update settings and stop scheduler
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
logger.info('Admin disabled auto sync', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
message='Auto sync setting change requested. Restart may be required.',
@@ -811,7 +817,7 @@ async def run_auto_sync_now(
detail='Auto sync service is not available',
)
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
logger.info('Admin triggered manual sync', telegram_id=admin.telegram_id)
result = await remnawave_sync_service.run_sync_now(reason='manual')
return AutoSyncRunResponse(
@@ -839,7 +845,7 @@ async def sync_from_panel(
try:
stats = await service.sync_users_from_panel(db, payload.mode)
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
logger.info('Admin synced from panel (mode: )', telegram_id=admin.telegram_id, mode=payload.mode)
return SyncResponse(
success=True,
message='Sync from panel completed',
@@ -862,7 +868,7 @@ async def sync_to_panel(
_ensure_configured(service)
stats = await service.sync_users_to_panel(db)
logger.info(f'Admin {admin.telegram_id} synced to panel')
logger.info('Admin synced to panel', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -892,9 +898,15 @@ async def sync_servers(
try:
await cache.delete_pattern('available_countries*')
except Exception as e:
logger.warning(f'Failed to clear countries cache: {e}')
logger.warning('Failed to clear countries cache', error=e)
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
logger.info(
'Admin synced servers: created=, updated=, removed',
telegram_id=admin.telegram_id,
created=created,
updated=updated,
removed=removed,
)
return SyncResponse(
success=True,
@@ -918,7 +930,7 @@ async def validate_subscriptions(
_ensure_configured(service)
stats = await service.validate_and_fix_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
logger.info('Admin validated subscriptions', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -937,7 +949,7 @@ async def cleanup_subscriptions(
_ensure_configured(service)
stats = await service.cleanup_orphaned_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
logger.info('Admin cleaned up subscriptions', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
@@ -956,7 +968,7 @@ async def sync_subscription_statuses(
_ensure_configured(service)
stats = await service.sync_subscription_statuses(db)
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
logger.info('Admin synced subscription statuses', telegram_id=admin.telegram_id)
return SyncResponse(
success=True,
+7 -8
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing servers in cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,7 +30,7 @@ from ..schemas.servers import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
@@ -184,7 +183,7 @@ async def update_existing_server(
if request.promo_group_ids is not None:
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated server {server_id}')
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
return await get_server(server_id, admin, db)
@@ -207,7 +206,7 @@ async def toggle_server(
await update_server_squad(db, server_id, is_available=new_status)
status_text = 'enabled' if new_status else 'disabled'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerToggleResponse(
id=server_id,
@@ -234,7 +233,7 @@ async def toggle_server_trial(
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = 'enabled for trial' if new_status else 'disabled for trial'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
return ServerTrialToggleResponse(
id=server_id,
@@ -311,7 +310,7 @@ async def sync_servers(
# Sync with database
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
return ServerSyncResponse(
created=created,
@@ -323,7 +322,7 @@ async def sync_servers(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to sync servers: {e}')
logger.error('Failed to sync servers', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync failed: {e!s}',
+4 -4
View File
@@ -1,8 +1,8 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +16,7 @@ from app.services.system_settings_service import (
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
@@ -248,7 +248,7 @@ async def update_setting(
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
return _serialize_definition(definition)
@@ -270,5 +270,5 @@ async def reset_setting(
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
return _serialize_definition(definition)
+18 -16
View File
@@ -1,10 +1,10 @@
"""Admin routes for statistics dashboard in cabinet."""
import logging
import sys
import time
from datetime import datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, func, select
@@ -29,7 +29,7 @@ from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
_start_time = time.time()
@@ -317,7 +317,7 @@ async def get_dashboard_stats(
)
except Exception as e:
logger.error(f'Failed to get dashboard stats: {e}')
logger.error('Failed to get dashboard stats', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load dashboard statistics',
@@ -349,7 +349,7 @@ async def get_system_info(
subscriptions_active=subscriptions_active,
)
except Exception as e:
logger.error(f'Failed to get system info: {e}')
logger.error('Failed to get system info', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load system information',
@@ -364,7 +364,7 @@ async def get_nodes_status(
try:
return await _get_nodes_overview()
except Exception as e:
logger.error(f'Failed to get nodes status: {e}')
logger.error('Failed to get nodes status', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load nodes status',
@@ -382,7 +382,7 @@ async def restart_node(
success = await service.manage_node(node_uuid, 'restart')
if success:
logger.info(f'Admin {admin.id} restarted node {node_uuid}')
logger.info('Admin restarted node', admin_id=admin.id, node_uuid=node_uuid)
return {'success': True, 'message': 'Node restart initiated'}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -391,7 +391,7 @@ async def restart_node(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to restart node {node_uuid}: {e}')
logger.error('Failed to restart node', node_uuid=node_uuid, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to restart node',
@@ -420,7 +420,7 @@ async def toggle_node(
success = await service.manage_node(node_uuid, action)
if success:
logger.info(f'Admin {admin.id} {action}d node {node_uuid}')
logger.info('Admin d node', admin_id=admin.id, action=action, node_uuid=node_uuid)
return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -429,7 +429,7 @@ async def toggle_node(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to toggle node {node_uuid}: {e}')
logger.error('Failed to toggle node', node_uuid=node_uuid, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to toggle node',
@@ -480,7 +480,7 @@ async def _get_nodes_overview() -> NodesOverview:
nodes=node_statuses,
)
except Exception as e:
logger.warning(f'Failed to get nodes from RemnaWave: {e}')
logger.warning('Failed to get nodes from RemnaWave', error=e)
# Return empty data if RemnaWave is unavailable
return NodesOverview(
total=0,
@@ -560,7 +560,9 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
)
purchased_month = month_result.scalar() or 0
logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}")
logger.info(
'📊 Тариф активных=, триал', tariff_name=tariff.name, active_count=active_count, trial_count=trial_count
)
tariff_items.append(
TariffStatItem(
@@ -576,7 +578,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
total_tariff_subscriptions += active_count
logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}')
logger.info('📊 Всего подписок по тарифам', total_tariff_subscriptions=total_tariff_subscriptions)
return TariffStats(
tariffs=tariff_items,
@@ -584,7 +586,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
)
except Exception as e:
logger.error(f'Failed to get tariff stats: {e}', exc_info=True)
logger.error('Failed to get tariff stats', error=e, exc_info=True)
return None
@@ -800,7 +802,7 @@ async def get_top_referrers(
)
except Exception as e:
logger.error(f'Failed to get top referrers: {e}', exc_info=True)
logger.error('Failed to get top referrers', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load referrers statistics',
@@ -857,7 +859,7 @@ async def get_top_campaigns(
)
except Exception as e:
logger.error(f'Failed to get top campaigns: {e}', exc_info=True)
logger.error('Failed to get top campaigns', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load campaigns statistics',
@@ -996,7 +998,7 @@ async def get_recent_payments(
)
except Exception as e:
logger.error(f'Failed to get recent payments: {e}', exc_info=True)
logger.error('Failed to get recent payments', error=e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load recent payments',
+14 -9
View File
@@ -1,7 +1,6 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,7 +37,7 @@ from ..schemas.tariffs import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
@@ -169,7 +168,7 @@ async def update_tariff_order(
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
return {'message': 'Tariff order updated successfully'}
@@ -295,7 +294,7 @@ async def create_new_tariff(
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -390,7 +389,7 @@ async def update_existing_tariff(
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -414,7 +413,13 @@ async def delete_existing_tariff(
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await delete_tariff(db, tariff)
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
logger.info(
'Admin deleted tariff (affected subscriptions: )',
admin_id=admin.id,
tariff_id=tariff_id,
tariff_name=tariff.name,
subs_count=subs_count,
)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -440,7 +445,7 @@ async def toggle_tariff(
await update_tariff(db, tariff, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
@@ -483,7 +488,7 @@ async def toggle_trial_tariff(
await update_tariff(db, tariff, is_trial_available=new_status)
status_text = 'set as trial' if new_status else 'removed from trial'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
return TariffTrialResponse(
id=tariff_id,
+6 -6
View File
@@ -1,9 +1,9 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
@@ -20,7 +20,7 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
@@ -317,7 +317,7 @@ async def update_ticket_settings(
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
logger.warning('Failed to update .env file', error=e)
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
@@ -473,11 +473,11 @@ async def reply_to_ticket(
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f'Failed to notify user about ticket reply: {e}')
logger.warning('Failed to notify user about ticket reply', error=e)
finally:
await bot.session.close()
except Exception as e:
logger.warning(f'Failed to send Telegram notification: {e}')
logger.warning('Failed to send Telegram notification', error=e)
# Уведомить пользователя в кабинете
try:
@@ -488,7 +488,7 @@ async def reply_to_ticket(
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
except Exception as e:
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
logger.warning('Failed to create cabinet notification for admin reply', error=e)
return _message_to_response(message)
+4 -4
View File
@@ -3,10 +3,10 @@
import asyncio
import csv
import io
import logging
import time
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -32,7 +32,7 @@ from ..schemas.traffic import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
@@ -110,7 +110,7 @@ async def _aggregate_traffic(
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
return node.uuid, stats
except Exception:
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True)
return node.uuid, None
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
@@ -685,7 +685,7 @@ async def export_traffic_csv(
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
)
except Exception:
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send CSV report. Please try again later.',
+5 -5
View File
@@ -1,9 +1,9 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
import aiohttp
import structlog
from fastapi import APIRouter, Depends
from pydantic import BaseModel
@@ -13,7 +13,7 @@ from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
@@ -76,15 +76,15 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
logger.error('Error fetching cabinet releases', e=e)
return _cabinet_cache.get('releases', [])
+76 -51
View File
@@ -1,8 +1,8 @@
"""Admin routes for managing users in cabinet."""
import logging
from datetime import datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -91,7 +91,7 @@ from ..schemas.users import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/users', tags=['Cabinet Admin Users'])
@@ -219,7 +219,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
service = RemnaWaveService()
if not service.is_configured:
logger.warning(f'Remnawave not configured, skipping panel sync for user {user.id}')
logger.warning('Remnawave not configured, skipping panel sync for user', user_id=user.id)
return {'skipped': True, 'reason': 'Remnawave not configured'}
is_active = (
@@ -260,7 +260,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
panel_uuid = None
user.remnawave_uuid = None
@@ -299,7 +299,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
try:
await api.update_user(**update_kwargs)
changes['action'] = 'updated'
logger.info(f'Updated user {user.id} in Remnawave panel')
logger.info('Updated user in Remnawave panel', user_id=user.id)
except Exception as update_error:
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
panel_uuid = None # Will create new
@@ -328,7 +328,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
subscription.subscription_url = new_panel_user.subscription_url
changes['action'] = 'created'
changes['panel_uuid'] = new_panel_user.uuid
logger.info(f'Created user {user.id} in Remnawave panel: {new_panel_user.uuid}')
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
user.last_remnawave_sync = datetime.utcnow()
await db.commit()
@@ -336,7 +336,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
return changes
except Exception as e:
logger.error(f'Error syncing user {user.id} to panel: {e}')
logger.error('Error syncing user to panel', user_id=user.id, error=e)
return {'error': str(e)}
@@ -709,7 +709,7 @@ async def get_user_panel_info(
last_node_name = node.node_name
break
except Exception:
logger.warning(f'Failed to resolve node name for user {user_id}')
logger.warning('Failed to resolve node name for user', user_id=user_id)
return UserPanelInfoResponse(
found=True,
@@ -728,7 +728,7 @@ async def get_user_panel_info(
)
except Exception as e:
logger.error(f'Error getting panel info for user {user_id}: {e}')
logger.error('Error getting panel info for user', user_id=user_id, error=e)
return UserPanelInfoResponse(found=False)
@@ -812,7 +812,7 @@ async def get_user_node_usage(
return UserNodeUsageResponse(items=items, categories=categories)
except Exception as e:
logger.error(f'Error getting node usage for user {user_id}: {e}')
logger.error('Error getting node usage for user', user_id=user_id, error=e)
return UserNodeUsageResponse(items=[])
@@ -877,8 +877,12 @@ async def update_user_balance(
await db.refresh(user)
logger.info(
f'Admin {admin.id} updated balance for user {user_id}: '
f'{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})'
'Admin updated balance for user',
admin_id=admin.id,
user_id=user_id,
old_balance=old_balance,
balance_kopeks=user.balance_kopeks,
amount_kopeks=format(request.amount_kopeks, '+d'),
)
return UpdateBalanceResponse(
@@ -962,7 +966,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, new_sub)
logger.info(f'Admin {admin.id} created subscription for user {user_id}')
logger.info('Admin created subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -989,7 +993,9 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} extended subscription for user {user_id} by {request.days} days')
logger.info(
'Admin extended subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
)
return UpdateSubscriptionResponse(
success=True,
@@ -1016,7 +1022,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} set end_date for user {user_id} subscription')
logger.info('Admin set end_date for user subscription', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1050,7 +1056,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} changed tariff for user {user_id} to {tariff.name}')
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
return UpdateSubscriptionResponse(
success=True,
@@ -1071,7 +1077,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} updated traffic for user {user_id}')
logger.info('Admin updated traffic for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1091,7 +1097,7 @@ async def update_user_subscription(
await db.refresh(subscription)
state = 'enabled' if request.autopay_enabled else 'disabled'
logger.info(f'Admin {admin.id} {state} autopay for user {user_id}')
logger.info('Admin autopay for user', admin_id=admin.id, state=state, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1108,7 +1114,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} cancelled subscription for user {user_id}')
logger.info('Admin cancelled subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1127,7 +1133,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} activated subscription for user {user_id}')
logger.info('Admin activated subscription for user', admin_id=admin.id, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1151,7 +1157,7 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}')
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
return UpdateSubscriptionResponse(
success=True,
@@ -1211,7 +1217,11 @@ async def update_user_subscription(
await _sync_subscription_to_panel(db, user, subscription)
logger.info(
f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}'
'Admin removed traffic purchase ( GB) for user',
admin_id=admin.id,
traffic_purchase_id=request.traffic_purchase_id,
removed_gb=removed_gb,
user_id=user_id,
)
return UpdateSubscriptionResponse(
@@ -1234,7 +1244,9 @@ async def update_user_subscription(
# Sync to Remnawave panel
await _sync_subscription_to_panel(db, user, subscription)
logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}')
logger.info(
'Admin set device limit to for user', admin_id=admin.id, device_limit=request.device_limit, user_id=user_id
)
return UpdateSubscriptionResponse(
success=True,
@@ -1384,7 +1396,7 @@ async def update_user_status(
if request.reason:
action += f' (reason: {request.reason})'
logger.info(f'Admin {admin.id} changed status for user {user_id}: {action}')
logger.info('Admin changed status for user', admin_id=admin.id, user_id=user_id, action=action)
return UpdateUserStatusResponse(
success=True,
@@ -1449,8 +1461,11 @@ async def update_user_restrictions(
await db.refresh(user)
logger.info(
f'Admin {admin.id} updated restrictions for user {user_id}: '
f'topup={user.restriction_topup}, subscription={user.restriction_subscription}'
'Admin updated restrictions for user topup=, subscription',
admin_id=admin.id,
user_id=user_id,
restriction_topup=user.restriction_topup,
restriction_subscription=user.restriction_subscription,
)
return UpdateRestrictionsResponse(
@@ -1501,7 +1516,11 @@ async def update_user_promo_group(
await db.refresh(user)
logger.info(
f'Admin {admin.id} changed promo group for user {user_id}: {old_promo_group_id} -> {new_promo_group_id}'
'Admin changed promo group for user',
admin_id=admin.id,
user_id=user_id,
old_promo_group_id=old_promo_group_id,
new_promo_group_id=new_promo_group_id,
)
return UpdatePromoGroupResponse(
@@ -1537,7 +1556,11 @@ async def update_user_referral_commission(
await db.commit()
logger.info(
f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}'
'Admin changed referral commission for user',
admin_id=admin.id,
user_id=user_id,
old_commission=old_commission,
commission_percent=request.commission_percent,
)
return UpdateReferralCommissionResponse(
@@ -1600,7 +1623,7 @@ async def get_user_devices(
)
except Exception as e:
logger.error(f'Error fetching devices for user {user_id}: {e}')
logger.error('Error fetching devices for user', user_id=user_id, error=e)
return UserDevicesResponse()
@@ -1627,12 +1650,12 @@ async def delete_user_device(
success = await api.remove_device(user.remnawave_uuid, hwid)
if success:
logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}')
logger.info('Admin deleted device for user', admin_id=admin.id, hwid=hwid, user_id=user_id)
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
return DeleteDeviceResponse(success=False, message='Failed to delete device')
except Exception as e:
logger.error(f'Error deleting device {hwid} for user {user_id}: {e}')
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
return DeleteDeviceResponse(success=False, message=str(e))
@@ -1672,11 +1695,11 @@ async def reset_user_devices(
except Exception:
pass
logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}')
logger.info('Admin reset devices for user /', admin_id=admin.id, user_id=user_id, deleted=deleted, total=total)
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
except Exception as e:
logger.error(f'Error resetting devices for user {user_id}: {e}')
logger.error('Error resetting devices for user', user_id=user_id, error=e)
return ResetDevicesResponse(success=False, message=str(e))
@@ -1713,7 +1736,7 @@ async def delete_user(
action = 'permanently deleted'
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} {action} user {user_id}{reason_text}')
logger.info('Admin user', admin_id=admin.id, action=action, user_id=user_id, reason_text=reason_text)
return DeleteUserResponse(
success=True,
@@ -1756,7 +1779,7 @@ async def full_delete_user(
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return FullDeleteUserResponse(
success=success,
@@ -1800,9 +1823,9 @@ async def reset_user_trial(
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
# Delete subscription from database
from sqlalchemy import delete
@@ -1819,7 +1842,7 @@ async def reset_user_trial(
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
logger.info('Admin reset trial for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return ResetTrialResponse(
success=True,
@@ -1871,10 +1894,10 @@ async def reset_user_subscription(
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
# Delete subscription from database
from sqlalchemy import delete
@@ -1888,7 +1911,7 @@ async def reset_user_subscription(
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
logger.info('Admin reset subscription for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return ResetSubscriptionResponse(
success=True,
@@ -1933,10 +1956,10 @@ async def disable_user(
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user: {e}')
logger.warning('Failed to disable Remnawave user', error=e)
# Deactivate subscription in bot database
if user.subscription:
@@ -1944,7 +1967,7 @@ async def disable_user(
await deactivate_subscription(db, user.subscription)
subscription_deactivated = True
logger.info(f'Deactivated subscription for user {user_id}')
logger.info('Deactivated subscription for user', user_id=user_id)
# Block user account
user.status = UserStatus.BLOCKED.value
@@ -1952,7 +1975,7 @@ async def disable_user(
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
logger.info('Admin disabled user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
return DisableUserResponse(
success=True,
@@ -2199,7 +2222,7 @@ async def get_user_sync_status(
differences.append(f'Squads mismatch ({", ".join(squad_diff_parts)})')
except Exception as e:
logger.warning(f'Failed to get panel data for user {user_id}: {e}')
logger.warning('Failed to get panel data for user', user_id=user_id, error=e)
differences.append(f'Error fetching panel data: {e!s}')
return PanelSyncStatusResponse(
@@ -2409,7 +2432,9 @@ async def sync_user_from_panel(
await db.commit()
logger.info(f'Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}')
logger.info(
'Admin synced user from panel. Changes', admin_id=admin.id, user_id=user_id, value=list(changes.keys())
)
return SyncFromPanelResponse(
success=True,
@@ -2422,7 +2447,7 @@ async def sync_user_from_panel(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error syncing user {user_id} from panel: {e}')
logger.error('Error syncing user from panel', user_id=user_id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
@@ -2510,7 +2535,7 @@ async def sync_user_to_panel(
if panel_uuid:
existing_user = await api.get_user_by_uuid(panel_uuid)
if not existing_user:
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
panel_uuid = None
user.remnawave_uuid = None
@@ -2600,7 +2625,7 @@ async def sync_user_to_panel(
await db.commit()
logger.info(f'Admin {admin.id} synced user {user_id} to panel. Action: {action}')
logger.info('Admin synced user to panel. Action', admin_id=admin.id, user_id=user_id, action=action)
return SyncToPanelResponse(
success=True,
@@ -2614,7 +2639,7 @@ async def sync_user_to_panel(
except HTTPException:
raise
except Exception as e:
logger.error(f'Error syncing user {user_id} to panel: {e}')
logger.error('Error syncing user to panel', user_id=user_id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync error: {e!s}',
+7 -7
View File
@@ -2,10 +2,10 @@
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,7 +35,7 @@ from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@@ -107,7 +107,7 @@ async def update_admin_wheel_config(
config = await update_wheel_config(db, **update_data)
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
@@ -211,7 +211,7 @@ async def create_prize(
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -261,7 +261,7 @@ async def update_prize(
detail='Prize not found',
)
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
return WheelPrizeAdminResponse(
id=prize.id,
@@ -298,7 +298,7 @@ async def delete_prize_endpoint(
detail='Prize not found',
)
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
@@ -309,7 +309,7 @@ async def reorder_prizes(
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
return {'success': True}
+45 -22
View File
@@ -2,9 +2,9 @@
import asyncio
import hashlib
import logging
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -69,7 +69,7 @@ from ..services.email_service import email_service
from ..services.email_template_overrides import get_rendered_override
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth', tags=['Cabinet Auth'])
@@ -158,12 +158,12 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
panel_users = await api.get_user_by_email(user.email)
if not panel_users:
logger.debug(f'No subscription found in panel for email: {user.email}')
logger.debug('No subscription found in panel for email', email=user.email)
return
# Take first user if multiple found
panel_user = panel_users[0]
logger.info(f'Found subscription in panel for email {user.email}: {panel_user.uuid}')
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
# Link user to panel
user.remnawave_uuid = panel_user.uuid
@@ -208,7 +208,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub.device_limit = device_limit
existing_sub.is_trial = False # Panel subscription is not trial
logger.info(
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
'Updated subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
)
else:
# Create new subscription (expire_at and current_time already naive UTC)
@@ -228,13 +231,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
)
db.add(new_sub)
logger.info(
f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
'Created subscription for email user squads: devices',
email=user.email,
connected_squads=connected_squads,
device_limit=device_limit,
)
await db.commit()
except Exception as e:
logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}')
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
# Don't rollback - it detaches user object and breaks subsequent operations
# The sync is non-critical, main verification already succeeded
@@ -275,7 +281,7 @@ async def auth_telegram(
if not user:
# Create new user from Telegram initData
logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}')
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
user = await create_user(
db=db,
telegram_id=telegram_id,
@@ -284,7 +290,7 @@ async def auth_telegram(
last_name=tg_last_name,
language=tg_language,
)
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
else:
# Update user info from initData (like bot middleware does)
updated = False
@@ -298,7 +304,7 @@ async def auth_telegram(
user.last_name = tg_last_name
updated = True
if updated:
logger.info(f'User {user.id} profile updated from initData')
logger.info('User profile updated from initData', user_id=user.id)
if user.status != 'active':
raise HTTPException(
@@ -341,7 +347,9 @@ async def auth_telegram_widget(
if not user:
# Create new user from Telegram data
logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}')
logger.info(
'Creating new user from cabinet: telegram_id=, username', request_id=request.id, username=request.username
)
user = await create_user(
db=db,
telegram_id=request.id,
@@ -350,7 +358,7 @@ async def auth_telegram_widget(
last_name=request.last_name,
language='ru',
)
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
if user.status != 'active':
raise HTTPException(
@@ -485,7 +493,7 @@ async def register_email_standalone(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid test email password',
)
logger.info(f'Test email registration: {request.email}')
logger.info('Test email registration', email=request.email)
# Check for disposable email
if disposable_email_service.is_disposable(request.email):
@@ -512,11 +520,17 @@ async def register_email_standalone(
if referrer:
# Защита от самореферала - нельзя регистрироваться по своему же коду
if referrer.email and referrer.email.lower() == request.email.lower():
logger.warning(f'Self-referral attempt blocked: email={request.email}, code={request.referral_code}')
logger.warning(
'Self-referral attempt blocked: email=, code',
email=request.email,
referral_code=request.referral_code,
)
referrer = None
else:
logger.info(
f'Found referrer for email registration: referrer_id={referrer.id}, code={request.referral_code}'
'Found referrer for email registration: referrer_id=, code',
referrer_id=referrer.id,
referral_code=request.referral_code,
)
# Создать пользователя
@@ -534,7 +548,7 @@ async def register_email_standalone(
user.email_verified = True
user.email_verified_at = datetime.utcnow()
await db.commit()
logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}')
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -579,9 +593,11 @@ async def register_email_standalone(
if referrer:
try:
await process_referral_registration(db, user.id, referrer.id, bot=None)
logger.info(f'Processed referral registration: user_id={user.id}, referrer_id={referrer.id}')
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
except Exception as e:
logger.error(f'Failed to process referral registration: {e}')
logger.error('Failed to process referral registration', error=e)
# Не прерываем регистрацию из-за ошибки реферальной системы
# Для тестового email - сразу можно логиниться (уже verified)
@@ -724,7 +740,7 @@ async def login_email(
if not user:
# For test email - auto-create user if not exists
if is_test_email and settings.validate_test_email_password(request.email, request.password):
logger.info(f'Test email login - creating new user: {request.email}')
logger.info('Test email login creating new user', email=request.email)
password_hash = hash_password(request.password)
user = await create_user_by_email(
db=db,
@@ -1051,9 +1067,16 @@ async def request_email_change(
custom_body_html=custom_body,
)
except Exception as e:
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
logger.error(
'Failed to send verification email to for user',
new_email=request.new_email,
user_id=user.id,
error=e,
)
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
logger.info(
'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email
)
return EmailChangeResponse(
message='Email replaced, verification sent to new address',
@@ -1104,7 +1127,7 @@ async def request_email_change(
detail='Email service is not configured',
)
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email)
return EmailChangeResponse(
message='Verification code sent to new email',
+10 -8
View File
@@ -1,11 +1,11 @@
"""Balance and payment routes for cabinet."""
import logging
import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -41,7 +41,7 @@ from ..schemas.balance import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
@@ -249,7 +249,7 @@ async def create_stars_invoice(
if stars_amount <= 0:
stars_amount = 1
except Exception as e:
logger.error(f'Error calculating Stars amount: {e}')
logger.error('Error calculating Stars amount', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to calculate Stars amount',
@@ -279,7 +279,7 @@ async def create_stars_invoice(
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Stars invoice',
@@ -287,8 +287,10 @@ async def create_stars_invoice(
invoice_url = result['result']
logger.info(
f'Created Stars invoice for balance top-up: user={user.id}, '
f'amount={request.amount_kopeks} kopeks, stars={stars_amount}'
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
user_id=user.id,
amount_kopeks=request.amount_kopeks,
stars_amount=stars_amount,
)
return StarsInvoiceResponse(
@@ -298,7 +300,7 @@ async def create_stars_invoice(
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating Stars invoice: {e}')
logger.error('HTTP error creating Stars invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to connect to Telegram API',
@@ -710,7 +712,7 @@ async def create_topup(
except HTTPException:
raise
except Exception as e:
logger.error(f'Payment creation error: {e}')
logger.error('Payment creation error', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create payment. Please try again later.',
+13 -13
View File
@@ -1,10 +1,10 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import json
import logging
import os
from pathlib import Path
import structlog
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
@@ -17,7 +17,7 @@ from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
@@ -307,7 +307,7 @@ async def update_branding_name(
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
# Return updated branding
custom_logo = has_custom_logo()
@@ -368,7 +368,7 @@ async def upload_logo(
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
@@ -398,7 +398,7 @@ async def delete_logo(
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
@@ -486,7 +486,7 @@ async def update_theme_colors(
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
return ThemeColorsResponse(**current_colors)
@@ -500,7 +500,7 @@ async def reset_theme_colors(
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@@ -558,7 +558,7 @@ async def update_enabled_themes(
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
return EnabledThemesResponse(**current_themes)
@@ -593,7 +593,7 @@ async def update_animation_enabled(
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return AnimationEnabledResponse(enabled=payload.enabled)
@@ -628,7 +628,7 @@ async def update_fullscreen_enabled(
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return FullscreenEnabledResponse(enabled=payload.enabled)
@@ -664,7 +664,7 @@ async def update_email_auth_enabled(
"""Update email auth enabled setting. Admin only."""
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return EmailAuthEnabledResponse(enabled=payload.enabled)
@@ -719,7 +719,7 @@ async def update_analytics_counters(
if payload.google_ads_label is not None:
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id)
# Return current state
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
@@ -764,6 +764,6 @@ async def update_lite_mode_enabled(
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
return LiteModeEnabledResponse(enabled=payload.enabled)
+5 -5
View File
@@ -1,10 +1,10 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ from app.services.contest_rotation_service import (
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
@@ -106,7 +106,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
await db.commit()
await db.refresh(subscription)
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
return f'Subscription extended by {days} days'
if prize_type == 'balance':
@@ -125,10 +125,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
await db.commit()
await db.refresh(user)
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
logger.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
return f'Balance increased by {amount}'
logger.warning(f'Unknown prize type: {prize_type}')
logger.warning('Unknown prize type', prize_type=prize_type)
return f"Prize type '{prize_type}' not supported"
+2 -3
View File
@@ -1,7 +1,6 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,7 +15,7 @@ from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
+10 -5
View File
@@ -1,8 +1,8 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
import structlog
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -16,7 +16,7 @@ from app.database.models import User
from ..dependencies import get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
@@ -125,7 +125,12 @@ async def upload_media(
media_url = _build_media_url(request, media.file_id)
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
logger.info(
'User uploaded',
telegram_id=user.telegram_id,
media_type_normalized=media_type_normalized,
file_id=media.file_id,
)
return MediaUploadResponse(
media_type=media_type_normalized,
@@ -136,7 +141,7 @@ async def upload_media(
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to upload media',
@@ -187,7 +192,7 @@ async def download_media(
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to download media {file_id}: {error}')
logger.error('Failed to download media', file_id=file_id, error=error)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to download media',
+2 -2
View File
@@ -1,9 +1,9 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from typing import Any
import structlog
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +13,7 @@ from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
+7 -7
View File
@@ -1,8 +1,8 @@
"""OAuth 2.0 authentication routes for cabinet."""
import logging
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,7 +27,7 @@ from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
@@ -120,7 +120,7 @@ async def oauth_callback(
try:
token_data = await oauth_provider.exchange_code(request.code)
except Exception as exc:
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
@@ -130,7 +130,7 @@ async def oauth_callback(
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
@@ -139,7 +139,7 @@ async def oauth_callback(
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via %s for existing user %s', provider, user.id)
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
# 6. Find user by email (if verified) and link provider
@@ -147,7 +147,7 @@ async def oauth_callback(
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
# 7. Create new user
@@ -161,5 +161,5 @@ async def oauth_callback(
last_name=user_info.last_name,
username=user_info.username,
)
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider)
+2 -2
View File
@@ -1,8 +1,8 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
@@ -20,7 +20,7 @@ from app.services.poll_service import get_next_question, get_question_option, re
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
+2 -2
View File
@@ -1,9 +1,9 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, select
@@ -22,7 +22,7 @@ from app.services.promo_offer_service import promo_offer_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
+2 -3
View File
@@ -1,7 +1,6 @@
"""Promo code routes for cabinet."""
import logging
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +11,7 @@ from app.services.promocode_service import PromoCodeService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
+2 -2
View File
@@ -1,8 +1,8 @@
"""Referral program routes for cabinet."""
import logging
import math
import structlog
from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,7 +22,7 @@ from ..schemas.referral import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
+122 -79
View File
@@ -2,11 +2,11 @@
import base64
import json
import logging
import re
from datetime import UTC, datetime, timedelta
from typing import Any
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -58,7 +58,7 @@ from ..schemas.subscription import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
@@ -490,9 +490,9 @@ async def renew_subscription(
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for auto-renewal (cabinet) user {user.id}')
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
except Exception as e:
logger.error(f'Error saving cart for auto-renewal (cabinet): {e}')
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -550,7 +550,7 @@ async def renew_subscription(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for subscription renewal: {e}')
logger.error('Failed to send admin notification for subscription renewal', error=e)
response = {
'message': 'Subscription renewed successfully',
@@ -598,6 +598,8 @@ async def get_traffic_packages(
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
@@ -625,6 +627,8 @@ async def get_traffic_packages(
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
@@ -705,6 +709,11 @@ async def purchase_traffic(
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
@@ -732,6 +741,11 @@ async def purchase_traffic(
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
@@ -775,11 +789,13 @@ async def purchase_traffic(
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
f'Cart saved for traffic purchase (cabinet) user {user.id}: '
f'+{request.gb} GB, discount {traffic_discount_percent}%'
'Cart saved for traffic purchase (cabinet) user + discount',
user_id=user.id,
gb=request.gb,
traffic_discount_percent=traffic_discount_percent,
)
except Exception as e:
logger.error(f'Error saving cart for traffic purchase (cabinet): {e}')
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -819,7 +835,11 @@ async def purchase_traffic(
from datetime import timedelta
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
logger.info(f'Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}')
logger.info(
'Set traffic_reset_at for subscription',
subscription_id=subscription.id,
traffic_reset_at=subscription.traffic_reset_at,
)
await db.commit()
@@ -831,7 +851,7 @@ async def purchase_traffic(
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f'Failed to sync traffic with RemnaWave: {e}')
logger.error('Failed to sync traffic with RemnaWave', error=e)
# Создаём транзакцию
await create_transaction(
@@ -868,7 +888,7 @@ async def purchase_traffic(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for traffic purchase: {e}')
logger.error('Failed to send admin notification for traffic purchase', error=e)
response = {
'success': True,
@@ -932,9 +952,13 @@ async def purchase_devices_legacy(
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for device purchase (cabinet /devices) user {user.id}: +{request.devices} devices')
logger.info(
'Cart saved for device purchase (cabinet /devices) user + devices',
user_id=user.id,
devices=request.devices,
)
except Exception as e:
logger.error(f'Error saving cart for device purchase (cabinet /devices): {e}')
logger.error('Error saving cart for device purchase (cabinet /devices)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -1006,7 +1030,7 @@ async def purchase_devices_legacy(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for device purchase: {e}')
logger.error('Failed to send admin notification for device purchase', error=e)
response = {
'message': 'Devices added successfully',
@@ -1109,7 +1133,7 @@ async def get_trial_info(
if tariff_trial_days:
duration_days = tariff_trial_days
except Exception as e:
logger.error(f'Error getting trial tariff for info: {e}')
logger.error('Error getting trial tariff for info', error=e)
# Check if user already has an active subscription
if user.subscription:
@@ -1197,7 +1221,7 @@ async def activate_trial(
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
)
user.balance_kopeks -= price_kopeks
logger.info(f'User {user.id} paid {price_kopeks} kopeks for trial activation')
logger.info('User paid kopeks for trial activation', user_id=user.id, price_kopeks=price_kopeks)
# Get trial parameters from tariff if configured (same logic as bot handler)
trial_duration = settings.TRIAL_DURATION_DAYS
@@ -1229,9 +1253,14 @@ async def activate_trial(
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(f'Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}')
logger.info(
'Using trial tariff (ID: ) with squads',
trial_tariff_name=trial_tariff.name,
trial_tariff_id=trial_tariff.id,
trial_squads=trial_squads,
)
except Exception as e:
logger.error(f'Error getting trial tariff: {e}')
logger.error('Error getting trial tariff', error=e)
# Create trial subscription
subscription = await create_trial_subscription(
@@ -1244,7 +1273,7 @@ async def activate_trial(
tariff_id=tariff_id_for_trial,
)
logger.info(f'Trial subscription activated for user {user.id}')
logger.info('Trial subscription activated for user', user_id=user.id)
# Create RemnaWave user
try:
@@ -1253,7 +1282,7 @@ async def activate_trial(
await subscription_service.create_remnawave_user(db, subscription)
await db.refresh(subscription)
except Exception as e:
logger.error(f'Failed to create RemnaWave user for trial: {e}')
logger.error('Failed to create RemnaWave user for trial', error=e)
# Send admin notification about trial activation
try:
@@ -1272,7 +1301,7 @@ async def activate_trial(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send trial activation notification: {e}')
logger.error('Failed to send trial activation notification', error=e)
return _subscription_to_response(subscription)
@@ -1537,7 +1566,7 @@ async def get_purchase_options(
detail=str(e),
)
except Exception as e:
logger.error(f'Failed to build purchase options for user {user.id}: {e}')
logger.error('Failed to build purchase options for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load purchase options',
@@ -1582,7 +1611,7 @@ async def preview_purchase(
detail=str(e),
)
except Exception as e:
logger.error(f'Failed to calculate purchase preview for user {user.id}: {e}')
logger.error('Failed to calculate purchase preview for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to calculate price',
@@ -1644,7 +1673,7 @@ async def submit_purchase(
bot=None,
)
except Exception as notif_error:
logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}')
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
# Отправляем уведомление админам о покупке подписки
try:
@@ -1670,7 +1699,7 @@ async def submit_purchase(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for subscription purchase: {e}')
logger.error('Failed to send admin notification for subscription purchase', error=e)
return {
'success': True,
@@ -1702,9 +1731,9 @@ async def submit_purchase(
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for auto-purchase (cabinet /purchase) user {user.id}')
logger.info('Cart saved for auto-purchase (cabinet /purchase) user', user_id=user.id)
except Exception as cart_error:
logger.error(f'Error saving cart for auto-purchase (cabinet /purchase): {cart_error}')
logger.error('Error saving cart for auto-purchase (cabinet /purchase)', cart_error=cart_error)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -1716,7 +1745,7 @@ async def submit_purchase(
},
)
except Exception as e:
logger.error(f'Failed to submit purchase for user {user.id}: {e}')
logger.error('Failed to submit purchase for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process purchase',
@@ -1898,9 +1927,9 @@ async def purchase_tariff(
try:
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}')
logger.info('Cart saved for auto-purchase (cabinet) user tariff', user_id=user.id, tariff_id=tariff.id)
except Exception as e:
logger.error(f'Error saving cart for auto-purchase (cabinet): {e}')
logger.error('Error saving cart for auto-purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -2005,7 +2034,7 @@ async def purchase_tariff(
reset_reason='покупка тарифа (cabinet)',
)
except Exception as remnawave_error:
logger.error(f'Failed to sync subscription with RemnaWave: {remnawave_error}')
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -2019,9 +2048,9 @@ async def purchase_tariff(
'description': f'Продление тарифа {tariff.name} на {period_days} дней',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Tariff cart saved for auto-renewal (cabinet) user {user.id}')
logger.info('Tariff cart saved for auto-renewal (cabinet) user', user_id=user.id)
except Exception as e:
logger.error(f'Error saving tariff cart (cabinet): {e}')
logger.error('Error saving tariff cart (cabinet)', error=e)
await db.refresh(user)
@@ -2081,7 +2110,7 @@ async def purchase_tariff(
bot=None,
)
except Exception as notif_error:
logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}')
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
# Отправляем уведомление админам о покупке/продлении тарифа
try:
@@ -2110,14 +2139,14 @@ async def purchase_tariff(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for tariff purchase: {e}')
logger.error('Failed to send admin notification for tariff purchase', error=e)
return response
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to purchase tariff for user {user.id}: {e}')
logger.error('Failed to purchase tariff for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process tariff purchase',
@@ -2224,11 +2253,13 @@ async def purchase_devices(
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(
f'Cart saved for device purchase (cabinet) user {user.id}: '
f'+{request.devices} devices, discount {devices_discount_percent}%'
'Cart saved for device purchase (cabinet) user + devices, discount',
user_id=user.id,
devices=request.devices,
devices_discount_percent=devices_discount_percent,
)
except Exception as e:
logger.error(f'Error saving cart for device purchase (cabinet): {e}')
logger.error('Error saving cart for device purchase (cabinet)', error=e)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -2274,17 +2305,23 @@ async def purchase_devices(
else:
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error(f'Failed to sync devices with RemnaWave: {e}')
logger.error('Failed to sync devices with RemnaWave', error=e)
await db.refresh(user)
if devices_discount_percent > 0:
logger.info(
f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks '
f'(discount {devices_discount_percent}%, saved {discount_value} kopeks)'
'User purchased devices for kopeks (discount saved kopeks)',
user_id=user.id,
devices=request.devices,
price_kopeks=price_kopeks,
devices_discount_percent=devices_discount_percent,
discount_value=discount_value,
)
else:
logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks')
logger.info(
'User purchased devices for kopeks', user_id=user.id, devices=request.devices, price_kopeks=price_kopeks
)
# Отправляем уведомление админам
try:
@@ -2308,7 +2345,7 @@ async def purchase_devices(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for device purchase: {e}')
logger.error('Failed to send admin notification for device purchase', error=e)
response = {
'success': True,
@@ -2331,7 +2368,7 @@ async def purchase_devices(
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to purchase devices for user {user.id}: {e}')
logger.error('Failed to purchase devices for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Не удалось обработать покупку устройств',
@@ -2449,7 +2486,7 @@ async def save_traffic_cart(
'description': f'Докупка {request.gb} ГБ трафика',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB')
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
return {'success': True, 'cart_saved': True}
@@ -2524,7 +2561,9 @@ async def save_devices_cart(
'source': 'cabinet',
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f'Cart saved for device purchase (cabinet save-cart) user {user.id}: +{request.devices} devices')
logger.info(
'Cart saved for device purchase (cabinet save-cart) user + devices', user_id=user.id, devices=request.devices
)
return {'success': True, 'cart_saved': True}
@@ -2650,7 +2689,7 @@ def _load_app_config_from_file() -> dict[str, Any]:
if isinstance(data, dict):
return data
except Exception as e:
logger.error(f'Failed to load app-config.json: {e}')
logger.error('Failed to load app-config.json', error=e)
return {}
@@ -2793,10 +2832,11 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
# No scheme found
logger.debug(
f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', "
f'has blocks: {bool(app.get("blocks"))}, '
f'has buttons: {bool(app.get("buttons"))}, '
f'has urlScheme: {bool(app.get("urlScheme"))}'
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
get=app.get('name'),
get_2=bool(app.get('blocks')),
get_3=bool(app.get('buttons')),
get_4=bool(app.get('urlScheme')),
)
return '', False
@@ -2842,7 +2882,7 @@ def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get('name', 'unknown')
if url_scheme:
logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'")
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
@@ -2951,12 +2991,12 @@ async def _load_app_config_async() -> dict[str, Any]:
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(remnawave_uuid)
if config and config.config:
logger.debug(f'Loaded app config from RemnaWave: {remnawave_uuid}')
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
raw = dict(config.config)
raw['_isRemnawave'] = True
return raw
except Exception as e:
logger.warning(f'Failed to load RemnaWave config, falling back to file: {e}')
logger.warning('Failed to load RemnaWave config, falling back to file', error=e)
# Fallback to local file
return _load_app_config_from_file()
@@ -2987,21 +3027,21 @@ def _create_deep_link(
scheme, uses_crypto = _get_url_scheme_for_app(app)
if not scheme:
logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
return None
# Pick the correct payload based on which template the app uses
if uses_crypto:
if not subscription_crypto_link:
logger.debug(
f"_create_deep_link: app '{app.get('name', 'unknown')}' requires crypto link but none available"
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_crypto_link
else:
if not subscription_url:
logger.debug(
f"_create_deep_link: app '{app.get('name', 'unknown')}' requires subscription_url but none available"
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
)
return None
payload = subscription_url
@@ -3010,7 +3050,7 @@ def _create_deep_link(
try:
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
except Exception as e:
logger.warning(f'Failed to encode payload to base64: {e}')
logger.warning('Failed to encode payload to base64', error=e)
return f'{scheme}{payload}'
@@ -3219,7 +3259,7 @@ async def update_countries(
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов: {e}')
logger.error('Ошибка обновления счётчика серверов', error=e)
# Update connected squads
user.subscription.connected_squads = selected_countries
@@ -3234,7 +3274,7 @@ async def update_countries(
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f'Failed to sync countries with RemnaWave: {e}')
logger.error('Failed to sync countries with RemnaWave', error=e)
await db.refresh(user.subscription)
@@ -3569,7 +3609,7 @@ async def get_devices(
}
except Exception as e:
logger.error(f'Error fetching devices: {e}')
logger.error('Error fetching devices', error=e)
return {
'devices': [],
'total': 0,
@@ -3613,7 +3653,7 @@ async def delete_device(
}
except Exception as e:
logger.error(f'Error deleting device: {e}')
logger.error('Error deleting device', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete device',
@@ -3672,7 +3712,7 @@ async def delete_all_devices(
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
deleted_count += 1
except Exception as device_error:
logger.error(f'Error deleting device {device_hwid}: {device_error}')
logger.error('Error deleting device', device_hwid=device_hwid, device_error=device_error)
return {
'success': True,
@@ -3681,7 +3721,7 @@ async def delete_all_devices(
}
except Exception as e:
logger.error(f'Error deleting all devices: {e}')
logger.error('Error deleting all devices', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete devices',
@@ -3755,7 +3795,7 @@ async def get_device_reduction_info(
if response and 'response' in response:
connected_devices_count = response['response'].get('total', 0)
except Exception as e:
logger.error(f'Error getting connected devices count: {e}')
logger.error('Error getting connected devices count', error=e)
can_reduce = current_device_limit - min_device_limit
@@ -3839,8 +3879,11 @@ async def reduce_devices(
if connected_devices_count > new_device_limit:
devices_to_remove = connected_devices_count - new_device_limit
logger.info(
f'Removing {devices_to_remove} excess devices for user {user.id}: '
f'had {connected_devices_count}, new limit {new_device_limit}'
'Removing excess devices for user had new limit',
devices_to_remove=devices_to_remove,
user_id=user.id,
connected_devices_count=connected_devices_count,
new_device_limit=new_device_limit,
)
# Sort by date (oldest first) and remove the last ones
@@ -3857,11 +3900,11 @@ async def reduce_devices(
delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
devices_removed_count += 1
logger.info(f'Removed device {device_hwid} for user {user.id}')
logger.info('Removed device for user', device_hwid=device_hwid, user_id=user.id)
except Exception as del_error:
logger.error(f'Error removing device {device_hwid}: {del_error}')
logger.error('Error removing device', device_hwid=device_hwid, del_error=del_error)
except Exception as e:
logger.error(f'Error checking/removing devices: {e}')
logger.error('Error checking/removing devices', error=e)
old_device_limit = current_device_limit
@@ -3875,7 +3918,7 @@ async def reduce_devices(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error(f'Error updating RemnaWave user: {e}')
logger.error('Error updating RemnaWave user', error=e)
logger.info(
f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}'
@@ -4309,7 +4352,7 @@ async def switch_tariff(
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f'Failed to sync tariff switch with RemnaWave: {e}')
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
# Reset all devices on tariff switch
devices_reset = False
@@ -4319,9 +4362,9 @@ async def switch_tariff(
async with service.get_api_client() as api:
await api.reset_user_devices(user.remnawave_uuid)
devices_reset = True
logger.info(f'Reset all devices for user {user.id} on tariff switch')
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
except Exception as e:
logger.error(f'Failed to reset devices on tariff switch: {e}')
logger.error('Failed to reset devices on tariff switch', error=e)
await db.refresh(user)
await db.refresh(user.subscription)
@@ -4349,7 +4392,7 @@ async def switch_tariff(
finally:
await bot.session.close()
except Exception as e:
logger.error(f'Failed to send admin notification for tariff switch: {e}')
logger.error('Failed to send admin notification for tariff switch', error=e)
response = {
'success': True,
@@ -4448,7 +4491,7 @@ async def toggle_subscription_pause(
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
except Exception as e:
logger.error(f'Error enabling RemnaWave user on resume: {e}')
logger.error('Error enabling RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
@@ -4582,7 +4625,7 @@ async def switch_traffic_package(
else:
await subscription_service.create_remnawave_user(db, user.subscription)
except Exception as e:
logger.error(f'Failed to sync traffic switch with RemnaWave: {e}')
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
await db.refresh(user)
await db.refresh(user.subscription)
@@ -4723,7 +4766,7 @@ async def refresh_traffic(
}
except Exception as e:
logger.error(f'Error refreshing traffic for user {user.id}: {e}')
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to refresh traffic data',
+2 -2
View File
@@ -1,8 +1,8 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +13,7 @@ from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
+6 -6
View File
@@ -1,9 +1,9 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,7 +26,7 @@ from ..schemas.tickets import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
@@ -164,7 +164,7 @@ async def create_ticket(
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
logger.error('Error notifying admins about new ticket from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -173,7 +173,7 @@ async def create_ticket(
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for new ticket: {e}')
logger.error('Error creating cabinet notification for new ticket', error=e)
messages = [_message_to_response(m) for m in ticket.messages]
@@ -284,7 +284,7 @@ async def add_ticket_message(
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
# Уведомить админов в кабинете
try:
@@ -295,6 +295,6 @@ async def add_ticket_message(
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for user reply: {e}')
logger.error('Error creating cabinet notification for user reply', error=e)
return _message_to_response(message)
+18 -18
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import asyncio
import json
import logging
import structlog
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.cabinet.auth.jwt_handler import get_token_payload
@@ -14,7 +14,7 @@ from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter()
@@ -42,10 +42,10 @@ class CabinetConnectionManager:
self._admin_connections[user_id].add(websocket)
logger.debug(
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
user_id,
is_admin,
len(self._user_connections),
'Cabinet WS connected: user_id is_admin total_users',
user_id=user_id,
is_admin=is_admin,
user_connections_count=len(self._user_connections),
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
@@ -61,7 +61,7 @@ class CabinetConnectionManager:
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
@@ -79,7 +79,7 @@ class CabinetConnectionManager:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to user %d: %s', user_id, e)
logger.warning('Failed to send to user', user_id=user_id, e=e)
disconnected.add(ws)
# Cleanup disconnected
@@ -105,7 +105,7 @@ class CabinetConnectionManager:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to admin %d: %s', user_id, e)
logger.warning('Failed to send to admin', user_id=user_id, e=e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
@@ -152,7 +152,7 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
)
return user_id, is_admin
except (TimeoutError, OSError, ConnectionRefusedError) as e:
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
logger.error('Database connection error in WS token verification', e=str(e)[:200])
return None, False
@@ -165,7 +165,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
token = websocket.query_params.get('token')
if not token:
logger.debug('Cabinet WS: No token from %s', client_host)
logger.debug('Cabinet WS: No token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: No token')
@@ -175,7 +175,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug('Cabinet WS: Invalid token from %s', client_host)
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
@@ -184,9 +184,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
# Принимаем соединение
try:
await websocket.accept()
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
except Exception as e:
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
return
# Регистрируем подключение
@@ -213,17 +213,17 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
await websocket.send_json({'type': 'pong'})
except json.JSONDecodeError:
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
break
except WebSocketDisconnect:
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
except Exception as e:
logger.exception('Cabinet WS error: %s', e)
logger.exception('Cabinet WS error', e=e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
+7 -5
View File
@@ -2,11 +2,11 @@
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -32,7 +32,7 @@ from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
@@ -253,14 +253,16 @@ async def create_stars_invoice(
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
logger.error('Telegram API error', result=result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
invoice_url = result['result']
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
logger.info(
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
)
return StarsInvoiceResponse(
invoice_url=invoice_url,
@@ -268,7 +270,7 @@ async def create_stars_invoice(
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating invoice: {e}')
logger.error('HTTP error creating invoice', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
+6 -5
View File
@@ -1,14 +1,15 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class EmailService:
@@ -41,7 +42,7 @@ class EmailService:
if smtp.has_extn('auth'):
smtp.login(self.user, self.password)
else:
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
return smtp
@@ -94,11 +95,11 @@ class EmailService:
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
logger.info(f'Email sent successfully to {to_email}')
logger.info('Email sent successfully to', to_email=to_email)
return True
except Exception as e:
logger.error(f'Failed to send email to {to_email}: {e}')
logger.error('Failed to send email to', to_email=to_email, error=e)
return False
def send_verification_email(
@@ -4,17 +4,17 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_template_override(
@@ -56,7 +56,9 @@ async def get_template_override(
return None
except Exception as e:
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
logger.debug(
'Не удалось получить override шаблона /', notification_type=notification_type, language=language, e=e
)
return None
+31 -41
View File
@@ -1,7 +1,6 @@
import hashlib
import hmac
import html
import logging
import math
import os
import re
@@ -11,6 +10,7 @@ from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import structlog
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
@@ -23,7 +23,7 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [
USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$')
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class Settings(BaseSettings):
@@ -516,7 +516,9 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
@@ -750,15 +752,16 @@ class Settings(BaseSettings):
'default': 'default',
'full': 'default',
'standard': 'default',
'text': 'text',
'text_only': 'text',
'textual': 'text',
'minimal': 'text',
'cabinet': 'cabinet',
'text': 'cabinet',
'text_only': 'cabinet',
'textual': 'cabinet',
'minimal': 'cabinet',
}
mode = aliases.get(normalized, normalized)
if mode not in {'default', 'text'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
if mode not in {'default', 'cabinet'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
return mode
@field_validator('SERVER_STATUS_MODE', mode='before')
@@ -1294,7 +1297,7 @@ class Settings(BaseSettings):
raise ValueError
return time(hour=hours, minute=minutes)
except (ValueError, AttributeError):
logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value)
logger.warning('Некорректное значение ADMIN_REPORTS_SEND_TIME', send_time_value=value)
return None
def kopeks_to_rubles(self, kopeks: int) -> float:
@@ -1314,17 +1317,14 @@ class Settings(BaseSettings):
if len(cleaned) > 16:
logger.warning(
'Некорректная длина %s: максимум 16 символов, получено %s',
setting_name,
len(cleaned),
'Некорректная длина : максимум 16 символов, получено',
setting_name=setting_name,
cleaned_count=len(cleaned),
)
return None
if not USER_TAG_PATTERN.fullmatch(cleaned):
logger.warning(
'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание',
setting_name,
)
logger.warning('Некорректный формат : допустимы только A-Z, 0-9 и подчёркивание', setting_name=setting_name)
return None
return cleaned
@@ -1365,8 +1365,12 @@ class Settings(BaseSettings):
def get_main_menu_mode(self) -> str:
return getattr(self, 'MAIN_MENU_MODE', 'default')
def is_cabinet_mode(self) -> bool:
return self.get_main_menu_mode() == 'cabinet'
def is_text_main_menu_mode(self) -> bool:
return self.get_main_menu_mode() == 'text'
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
return self.is_cabinet_mode()
def get_main_menu_miniapp_url(self) -> str | None:
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
@@ -1441,9 +1445,9 @@ class Settings(BaseSettings):
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logging.getLogger(__name__).warning(
'Некорректный идентификатор бота для внешней админки: %s',
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
logger.warning(
'Некорректный идентификатор бота для внешней админки',
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
@@ -1524,10 +1528,7 @@ class Settings(BaseSettings):
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s',
raw_value,
)
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
return None
if value < 0:
@@ -1567,8 +1568,7 @@ class Settings(BaseSettings):
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
'Некорректное значение TRIAL_ACTIVATION_PRICE: %s',
self.TRIAL_ACTIVATION_PRICE,
'Некорректное значение TRIAL_ACTIVATION_PRICE', TRIAL_ACTIVATION_PRICE=self.TRIAL_ACTIVATION_PRICE
)
return 0
@@ -1687,7 +1687,7 @@ class Settings(BaseSettings):
try:
method_code = int(part)
except ValueError:
logger.warning('Некорректный код метода Platega: %s', part)
logger.warning('Некорректный код метода Platega', part=part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
@@ -1782,8 +1782,8 @@ class Settings(BaseSettings):
if minutes <= 0:
logger.warning(
'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.',
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
'Некорректный интервал автопроверки платежей: . Используется значение по умолчанию 10 минут.',
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
)
return 10
@@ -2137,22 +2137,13 @@ class Settings(BaseSettings):
return self.REFERRAL_NOTIFICATIONS_ENABLED
def get_traffic_packages(self) -> list[dict]:
import logging
logger = logging.getLogger(__name__)
try:
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.debug(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.debug('CONFIG EMPTY, USING FALLBACK')
return self._get_fallback_traffic_packages()
logger.debug('PARSING CONFIG...')
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
@@ -2171,11 +2162,10 @@ class Settings(BaseSettings):
except ValueError:
continue
logger.debug(f'PARSED {len(packages)} packages from config')
return packages if packages else self._get_fallback_traffic_packages()
except Exception as e:
logger.info(f'ERROR PARSING CONFIG: {e}')
logger.warning('ERROR PARSING CONFIG', error=e)
return self._get_fallback_traffic_packages()
def is_version_check_enabled(self) -> bool:
+9 -9
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -17,7 +17,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_campaign(
@@ -57,10 +57,10 @@ async def create_campaign(
await db.refresh(campaign)
logger.info(
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
'📣 Создана рекламная кампания (start bonus=)',
campaign_name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
)
return campaign
@@ -157,14 +157,14 @@ async def update_campaign(
await db.commit()
await db.refresh(campaign)
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
await db.commit()
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
logger.info('🗑️ Удалена рекламная кампания', campaign_name=campaign.name)
return True
@@ -217,7 +217,7 @@ async def record_campaign_registration(
await db.commit()
await db.refresh(registration)
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id)
return registration
+7 -9
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_cloudpayments_payment(
@@ -65,10 +65,10 @@ async def create_cloudpayments_payment(
await db.refresh(payment)
logger.debug(
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
payment.id,
invoice_id,
amount_kopeks,
'Created CloudPayments payment: id invoice amount',
payment_id=payment.id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
)
return payment
@@ -195,9 +195,7 @@ async def mark_cloudpayments_payment_as_paid(
await db.refresh(payment)
logger.info(
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
payment.id,
payment.invoice_id,
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
)
return payment
+2 -2
View File
@@ -1,7 +1,7 @@
import logging
from collections.abc import Sequence
from datetime import datetime
import structlog
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
# Templates
+11 -5
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_cryptobot_payment(
@@ -41,7 +41,13 @@ async def create_cryptobot_payment(
await db.commit()
await db.refresh(payment)
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
logger.info(
'Создан CryptoBot платеж: на для пользователя',
invoice_id=invoice_id,
amount=amount,
asset=asset,
user_id=user_id,
)
return payment
@@ -78,7 +84,7 @@ async def update_cryptobot_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
return payment
@@ -96,7 +102,7 @@ async def link_cryptobot_payment_to_transaction(
await db.commit()
await db.refresh(payment)
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
return payment
+6 -14
View File
@@ -1,8 +1,8 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -11,7 +11,7 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def upsert_discount_offer(
@@ -178,17 +178,12 @@ async def mark_offer_claimed(
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer claim log for offer %s: %s',
offer.id,
exc,
)
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer claim log failure: %s',
rollback_error,
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
)
return offer
@@ -239,16 +234,13 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer disable log for offer %s: %s',
payload.get('offer_id'),
exc,
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer disable log failure: %s',
rollback_error,
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
)
return count
+5 -5
View File
@@ -1,14 +1,14 @@
import logging
from collections.abc import Iterable
from datetime import datetime
import structlog
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
@@ -94,7 +94,7 @@ async def create_faq_page(
await db.commit()
await db.refresh(page)
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
logger.info('✅ Создана страница FAQ для языка', page_id=page.id, language=language)
return page
@@ -122,7 +122,7 @@ async def update_faq_page(
await db.commit()
await db.refresh(page)
logger.info('✅ Страница FAQ %s обновлена', page.id)
logger.info('✅ Страница FAQ обновлена', page_id=page.id)
return page
@@ -130,7 +130,7 @@ async def update_faq_page(
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info('🗑️ Страница FAQ %s удалена', page_id)
logger.info('🗑️ Страница FAQ удалена', page_id=page_id)
async def bulk_update_order(
+9 -4
View File
@@ -1,16 +1,16 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_freekassa_payment(
@@ -41,7 +41,7 @@ async def create_freekassa_payment(
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
return payment
@@ -92,7 +92,12 @@ async def update_freekassa_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
logger.info(
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
order_id=payment.order_id,
status=status,
is_paid=is_paid,
)
return payment
+16 -20
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_heleket_payment(
@@ -50,12 +50,12 @@ async def create_heleket_payment(
await db.refresh(payment)
logger.info(
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
uuid,
order_id,
amount,
currency,
user_id,
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
user_id=user_id,
)
return payment
@@ -107,7 +107,7 @@ async def update_heleket_payment(
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Heleket платеж с uuid=%s не найден', uuid)
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
return None
if status is not None:
@@ -135,11 +135,11 @@ async def update_heleket_payment(
await db.refresh(payment)
logger.info(
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
'Обновлен Heleket платеж : статус= payer_amount',
uuid=uuid,
payment_status=payment.status,
payer_amount=payment.payer_amount,
payer_currency=payment.payer_currency,
)
return payment
@@ -153,7 +153,7 @@ async def link_heleket_payment_to_transaction(
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
return None
payment.transaction_id = transaction_id
@@ -162,10 +162,6 @@ async def link_heleket_payment_to_transaction(
await db.commit()
await db.refresh(payment)
logger.info(
'Heleket платеж %s связан с транзакцией %s',
uuid,
transaction_id,
)
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
return payment
+9 -4
View File
@@ -1,16 +1,16 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_kassa_ai_payment(
@@ -43,7 +43,7 @@ async def create_kassa_ai_payment(
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
logger.info('Создан платеж KassaAI: order_id=, user_id', order_id=order_id, user_id=user_id)
return payment
@@ -94,7 +94,12 @@ async def update_kassa_ai_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
logger.info(
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
order_id=payment.order_id,
status=status,
is_paid=is_paid,
)
return payment
+8 -8
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +8,7 @@ from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_mulenpay_payment(
@@ -41,12 +41,12 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
amount_kopeks,
user_id,
'Создан платеж # (uuid=) на сумму копеек для пользователя',
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
mulen_payment_id=payment.mulen_payment_id,
uuid=uuid,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return payment
+2 -3
View File
@@ -1,12 +1,11 @@
import logging
import structlog
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def notification_sent(
+12 -16
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_pal24_payment(
@@ -51,11 +51,11 @@ async def create_pal24_payment(
await db.refresh(payment)
logger.info(
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
payment.id,
user_id,
amount_kopeks,
status,
'Создан Pal24 платеж # для пользователя : копеек (статус)',
payment_id=payment.id,
user_id=user_id,
amount_kopeks=amount_kopeks,
status=status,
)
return payment
@@ -128,10 +128,10 @@ async def update_pal24_payment_status(
await db.refresh(payment)
logger.info(
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
payment.bill_id,
payment.status,
payment.is_paid,
'Обновлен Pal24 платеж : статус is_paid',
bill_id=payment.bill_id,
payment_status=payment.status,
is_paid=payment.is_paid,
)
return payment
@@ -145,9 +145,5 @@ async def link_pal24_payment_to_transaction(
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
await db.commit()
await db.refresh(payment)
logger.info(
'Pal24 платеж %s привязан к транзакции %s',
payment.bill_id,
transaction_id,
)
logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id)
return payment
+7 -7
View File
@@ -2,17 +2,17 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_platega_payment(
@@ -55,11 +55,11 @@ async def create_platega_payment(
await db.refresh(payment)
logger.info(
'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s',
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
'Создан Platega платеж # (tx=) на сумму копеек для пользователя',
payment_id=payment.id,
platega_transaction_id=platega_transaction_id,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return payment
+3 -3
View File
@@ -1,6 +1,6 @@
import logging
from collections.abc import Iterable, Sequence
import structlog
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -14,7 +14,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_poll(
@@ -97,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
await db.delete(poll)
await db.commit()
logger.info('🗑️ Удалён опрос %s', poll_id)
logger.info('🗑️ Удалён опрос', poll_id=poll_id)
return True
+3 -7
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None:
@@ -38,11 +38,7 @@ async def upsert_privacy_policy(
await db.commit()
await db.refresh(policy)
logger.info(
'✅ Политика конфиденциальности для языка %s обновлена (ID: %s)',
language,
policy.id,
)
logger.info('✅ Политика конфиденциальности для языка обновлена (ID:)', language=language, policy_id=policy.id)
return policy
+7 -12
View File
@@ -1,5 +1,4 @@
import logging
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -25,7 +24,7 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict
return normalized
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_promo_groups_with_counts(
@@ -194,11 +193,7 @@ async def update_promo_group(
await db.commit()
await db.refresh(group)
logger.info(
"Обновлена промогруппа '%s' (id=%s)",
group.name,
group.id,
)
logger.info("Обновлена промогруппа '' (id=)", group_name=group.name, group_id=group.id)
return group
@@ -246,10 +241,10 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
await db.commit()
logger.info(
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
group.name,
group.id,
default_group.name,
"Промогруппа '' (id=) удалена, пользователи переведены в ''",
group_name=group.name,
group_id=group.id,
default_group_name=default_group.name,
)
return True
+2 -3
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import logging
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -9,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def log_promo_offer_action(
+20 -9
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None:
@@ -78,9 +78,9 @@ async def create_promocode(
await db.refresh(promocode)
if promo_group_id:
logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}')
logger.info('✅ Создан промокод: с промогруппой ID', code=code, promo_group_id=promo_group_id)
else:
logger.info(f'✅ Создан промокод: {code}')
logger.info('✅ Создан промокод', code=code)
return promocode
@@ -97,11 +97,11 @@ async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bo
await db.commit()
logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}')
logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id)
return True
except Exception as e:
logger.error(f'Ошибка использования промокода: {e}')
logger.error('Ошибка использования промокода', error=e)
await db.rollback()
return False
@@ -120,7 +120,7 @@ async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int
await db.commit()
await db.refresh(promocode_use)
logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}')
logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id)
return promocode_use
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
return result.scalar_one_or_none()
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(hours=hours)
result = await db.execute(
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
)
return result.scalar() or 0
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
@@ -183,11 +194,11 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
await db.delete(promocode)
await db.commit()
logger.info(f'🗑️ Удален промокод: {promocode.code}')
logger.info('🗑️ Удален промокод', code=promocode.code)
return True
except Exception as e:
logger.error(f'Ошибка удаления промокода: {e}')
logger.error('Ошибка удаления промокода', error=e)
await db.rollback()
return False
+3 -7
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None:
@@ -38,11 +38,7 @@ async def upsert_public_offer(
await db.commit()
await db.refresh(offer)
logger.info(
'✅ Публичная оферта для языка %s обновлена (ID: %s)',
language,
offer.id,
)
logger.info('✅ Публичная оферта для языка обновлена (ID:)', language=language, offer_id=offer.id)
return offer
+9 -4
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timedelta
import structlog
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import ReferralEarning, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_referral_earning(
@@ -31,7 +31,9 @@ async def create_referral_earning(
await db.commit()
await db.refresh(earning)
logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}')
logger.info(
'💰 Создан реферальный заработок: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id
)
return earning
@@ -211,7 +213,10 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
logger.info(
f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек'
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
users_with_referrals=users_with_referrals,
active_referrers=active_referrers,
total_paid=total_paid,
)
return {
+26 -15
View File
@@ -1,7 +1,7 @@
import logging
from collections.abc import Sequence
from datetime import date, datetime, time
import structlog
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -16,7 +16,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_referral_contest(
@@ -725,7 +725,12 @@ async def sync_contest_events(
# Конец дня: 23:59:59.999999
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
logger.info('Синхронизация конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
logger.info(
'Синхронизация конкурса : период с по',
contest_id=contest_id,
contest_start=contest_start,
contest_end=contest_end,
)
stats = {
'updated': 0,
@@ -815,7 +820,11 @@ async def sync_contest_events(
# Логируем значительные изменения
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
logger.debug(
'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid
'Событие (реферал): -> коп.',
event_id=event.id,
referral_id=event.referral_id,
old_amount=old_amount,
total_paid=total_paid,
)
else:
stats['skipped'] += 1
@@ -824,11 +833,11 @@ async def sync_contest_events(
await db.commit()
logger.info(
'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.',
contest_id,
stats['updated'],
stats['skipped'],
stats['total_amount'],
'Синхронизация конкурса завершена: обновлено , пропущено , сумма коп.',
contest_id=contest_id,
stats=stats['updated'],
stats_2=stats['skipped'],
stats_3=stats['total_amount'],
)
return stats
@@ -861,7 +870,9 @@ async def cleanup_invalid_contest_events(
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
logger.info('Очистка конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
logger.info(
'Очистка конкурса : период с по', contest_id=contest_id, contest_start=contest_start, contest_end=contest_end
)
# Считаем сколько было событий до очистки
total_before_result = await db.execute(
@@ -905,11 +916,11 @@ async def cleanup_invalid_contest_events(
remaining = int(remaining_result.scalar_one() or 0)
logger.info(
'Очистка конкурса %s завершена: удалено %s невалидных событий, осталось %s валидных (было %s)',
contest_id,
deleted,
remaining,
total_before,
'Очистка конкурса завершена: удалено невалидных событий, осталось валидных (было)',
contest_id=contest_id,
deleted=deleted,
remaining=remaining,
total_before=total_before,
)
return {
+13 -9
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import ServiceRule
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None:
@@ -38,7 +38,7 @@ async def create_or_update_rules(
await db.commit()
await db.refresh(new_rules)
logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})')
logger.info('✅ Правила для языка обновлены (ID: )', language=language, new_rules_id=new_rules.id)
return new_rules
@@ -53,12 +53,14 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool:
await db.commit()
rows_affected = result.rowcount
logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}')
logger.info(
'✅ Очищены правила для языка . Деактивировано записей', language=language, rows_affected=rows_affected
)
return rows_affected > 0
except Exception as e:
logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}')
logger.error('❌ Ошибка при очистке правил для языка', language=language, error=e)
await db.rollback()
raise
@@ -102,7 +104,7 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
rule_to_restore = result.scalar_one_or_none()
if not rule_to_restore:
logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}')
logger.warning('Правило с ID не найдено для языка', rule_id=rule_id, language=language)
return None
await db.execute(
@@ -119,11 +121,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
await db.commit()
await db.refresh(restored_rule)
logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}')
logger.info(
'✅ Восстановлена версия правил ID как новое правило ID', rule_id=rule_id, restored_rule_id=restored_rule.id
)
return restored_rule
except Exception as e:
logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}')
logger.error('❌ Ошибка при восстановлении правил ID', rule_id=rule_id, error=e)
await db.rollback()
raise
@@ -156,5 +160,5 @@ async def get_rules_statistics(db: AsyncSession) -> dict:
}
except Exception as e:
logger.error(f'❌ Ошибка при получении статистики правил: {e}')
logger.error('❌ Ошибка при получении статистики правил', error=e)
return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)}
+30 -34
View File
@@ -1,8 +1,8 @@
import logging
import random
from collections.abc import Iterable, Sequence
from datetime import datetime
import structlog
from sqlalchemy import (
String,
and_,
@@ -28,7 +28,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
@@ -64,7 +64,7 @@ async def create_server_squad(
promo_groups = promo_groups_result.scalars().all()
if len(promo_groups) != len(normalized_group_ids):
logger.warning('Не все промогруппы найдены при создании сервера %s', display_name)
logger.warning('Не все промогруппы найдены при создании сервера', display_name=display_name)
server_squad = ServerSquad(
squad_uuid=squad_uuid,
@@ -84,7 +84,7 @@ async def create_server_squad(
await db.commit()
await db.refresh(server_squad)
logger.info(f'✅ Создан сервер {display_name} (UUID: {squad_uuid})')
logger.info('✅ Создан сервер (UUID: )', display_name=display_name, squad_uuid=squad_uuid)
return server_squad
@@ -267,13 +267,17 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
connections_count = connections_result.scalar()
if connections_count > 0:
logger.warning(f'⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})')
logger.warning(
'⚠ Нельзя удалить сервер есть активные подключения',
server_id=server_id,
connections_count=connections_count,
)
return False
await db.execute(delete(ServerSquad).where(ServerSquad.id == server_id))
await db.commit()
logger.info(f'🗑️ Удален сервер (ID: {server_id})')
logger.info('🗑️ Удален сервер (ID: )', server_id=server_id)
return True
@@ -322,11 +326,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
'🗑️ Удаляется сервер %s (UUID: %s)',
server.display_name,
server.squad_uuid,
)
logger.info('🗑️ Удаляется сервер (UUID:)', display_name=server.display_name, squad_uuid=server.squad_uuid)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids)))
@@ -386,20 +386,14 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
'🧹 Обновлены подписки после удаления серверов: %s',
cleaned_subscriptions,
)
logger.info('🧹 Обновлены подписки после удаления серверов', cleaned_subscriptions=cleaned_subscriptions)
if cleaned_tariffs:
logger.info(
'🧹 Обновлены тарифы после удаления серверов: %s',
cleaned_tariffs,
)
logger.info('🧹 Обновлены тарифы после удаления серверов', cleaned_tariffs=cleaned_tariffs)
await db.commit()
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
logger.info('🔄 Синхронизация завершена: + ~', created=created, updated=updated, removed=removed)
return created, updated, removed
@@ -767,11 +761,11 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
)
await db.flush()
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
logger.info('✅ Увеличен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
return True
except Exception as e:
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
logger.error('Ошибка увеличения счетчика пользователей', error=e)
raise
@@ -785,11 +779,11 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
)
await db.flush()
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
logger.info('✅ Уменьшен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
return True
except Exception as e:
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
logger.error('Ошибка уменьшения счетчика пользователей', error=e)
raise
@@ -836,12 +830,12 @@ async def update_server_user_counts(
await db.flush()
if add_set:
logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set))
logger.info('✅ Увеличен счетчик пользователей для серверов', sorted=sorted(add_set))
if remove_set:
logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set))
logger.info('✅ Уменьшен счетчик пользователей для серверов', sorted=sorted(remove_set))
except Exception as e:
logger.error('Ошибка обновления счетчиков серверов: %s', e)
logger.error('Ошибка обновления счетчиков серверов', e=e)
raise
@@ -875,7 +869,7 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
server_count = result.scalar() or 0
if server_count > 0:
logger.info(f'В базе уже есть {server_count} серверов, пропускаем синхронизацию')
logger.info('✅ В базе уже есть серверов, пропускаем синхронизацию', server_count=server_count)
return
logger.info('🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...')
@@ -900,10 +894,10 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
# Синхронизируем
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'✅ Серверы синхронизированы: +{created} ~{updated} -{removed}')
logger.info('✅ Серверы синхронизированы: + ~', created=created, updated=updated, removed=removed)
except Exception as e:
logger.error(f'❌ Ошибка синхронизации серверов: {e}')
logger.error('❌ Ошибка синхронизации серверов', error=e)
async def sync_server_user_counts(db: AsyncSession) -> int:
@@ -911,7 +905,7 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
all_servers = all_servers_result.fetchall()
logger.info(f'🔍 Найдено серверов для синхронизации: {len(all_servers)}')
logger.info('🔍 Найдено серверов для синхронизации', all_servers_count=len(all_servers))
updated_count = 0
for server_id, squad_uuid in all_servers:
@@ -926,16 +920,18 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
)
actual_users = count_result.scalar() or 0
logger.info(f'📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей')
logger.info(
'📊 Сервер пользователей', server_id=server_id, squad_uuid=squad_uuid[:8], actual_users=actual_users
)
await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(current_users=actual_users))
updated_count += 1
await db.commit()
logger.info(f'✅ Синхронизированы счетчики для {updated_count} серверов')
logger.info('✅ Синхронизированы счетчики для серверов', updated_count=updated_count)
return updated_count
except Exception as e:
logger.error(f'Ошибка синхронизации счетчиков пользователей: {e}')
logger.error('Ошибка синхронизации счетчиков пользователей', error=e)
await db.rollback()
return 0
+3 -4
View File
@@ -1,12 +1,11 @@
import logging
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Squad
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Squad | None:
@@ -28,7 +27,7 @@ async def create_squad(
await db.commit()
await db.refresh(squad)
logger.info(f'✅ Создан сквад: {name}')
logger.info('✅ Создан сквад', name=name)
return squad
+208 -166
View File
@@ -1,8 +1,8 @@
import logging
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from typing import Optional
import structlog
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -22,7 +22,7 @@ from app.utils.pricing_utils import calculate_months_from_days, get_remaining_mo
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
_WEBHOOK_GUARD_SECONDS = 60
@@ -50,7 +50,10 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
if subscription:
logger.info(
f'🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}'
'🔍 Загружена подписка для пользователя статус',
subscription_id=subscription.id,
user_id=user_id,
status=subscription.status,
)
subscription = await check_and_update_subscription_status(db, subscription)
@@ -93,16 +96,10 @@ async def create_trial_subscription(
if random_squad:
final_squads = [random_squad]
logger.debug(
'Выбран сквад %s для триальной подписки пользователя %s',
random_squad,
user_id,
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
)
except Exception as error:
logger.error(
'Не удалось получить сквад для триальной подписки пользователя %s: %s',
user_id,
error,
)
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -119,9 +116,7 @@ async def create_trial_subscription(
await db.commit()
await db.refresh(existing)
logger.info(
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
existing.id,
user_id,
'🎁 Обновлена PENDING триальная подписка для пользователя', existing_id=existing.id, user_id=user_id
)
return existing
@@ -157,20 +152,14 @@ async def create_trial_subscription(
server_ids = await get_server_ids_by_uuids(db, final_squads)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
'📈 Обновлен счетчик пользователей для триальных сквадов %s',
final_squads,
)
logger.info('📈 Обновлен счетчик пользователей для триальных сквадов', final_squads=final_squads)
else:
logger.warning(
'⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)',
final_squads,
)
logger.warning('⚠️ Не удалось найти серверы для обновления счетчика (сквады)', final_squads=final_squads)
except Exception as error:
logger.error(
'⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s',
final_squads,
error,
'⚠️ Ошибка обновления счетчика пользователей для триальных сквадов',
final_squads=final_squads,
error=error,
)
return subscription
@@ -211,7 +200,10 @@ async def create_paid_subscription(
await db.refresh(subscription)
logger.info(
f'💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}'
'💎 Создана платная подписка для пользователя ID: статус',
user_id=user_id,
subscription_id=subscription.id,
status=subscription.status,
)
squad_uuids = list(connected_squads or [])
@@ -226,21 +218,21 @@ async def create_paid_subscription(
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
'📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)',
user_id,
squad_uuids,
'📈 Обновлен счетчик пользователей для платной подписки пользователя (сквады:)',
user_id=user_id,
squad_uuids=squad_uuids,
)
else:
logger.warning(
'⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)',
user_id,
squad_uuids,
'⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя (сквады:)',
user_id=user_id,
squad_uuids=squad_uuids,
)
except Exception as error:
logger.error(
'⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s',
user_id,
error,
'⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя',
user_id=user_id,
error=error,
)
return subscription
@@ -313,16 +305,16 @@ async def replace_subscription(
)
logger.info(
'♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s',
subscription.id,
len(squads_to_remove),
len(squads_to_add),
'♻️ Обновлены параметры подписки : удалено сквадов , добавлено',
subscription_id=subscription.id,
squads_to_remove_count=len(squads_to_remove),
squads_to_add_count=len(squads_to_add),
)
except Exception as error:
logger.error(
'⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s',
subscription.id,
error,
'⚠️ Ошибка обновления счетчиков серверов при замене подписки',
subscription_id=subscription.id,
error=error,
)
return subscription
@@ -351,9 +343,12 @@ async def extend_subscription(
"""
current_time = datetime.utcnow()
logger.info(f'🔄 Продление подписки {subscription.id} на {days} дней')
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
logger.info(
f'📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}'
'📊 Текущие параметры: статус=, окончание=, тариф',
status=subscription.status,
end_date=subscription.end_date,
tariff_id=subscription.tariff_id,
)
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
@@ -361,7 +356,7 @@ async def extend_subscription(
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
if is_tariff_change:
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id}{tariff_id}')
logger.info('🔄 Обнаружена СМЕНА тарифа: ', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id)
# Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла
# и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа.
@@ -371,9 +366,7 @@ async def extend_subscription(
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
'📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s',
abs(days),
subscription.end_date,
'📅 Срок подписки уменьшен на дней, новая дата окончания', abs=abs(days), end_date=subscription.end_date
)
elif is_tariff_change:
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
@@ -383,19 +376,19 @@ async def extend_subscription(
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
'🎁 Обнаружен остаток триала: %s дней для подписки %s',
bonus_days,
subscription.id,
'🎁 Обнаружен остаток триала: дней для подписки',
bonus_days=bonus_days,
subscription_id=subscription.id,
)
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
subscription.start_date = current_time
logger.info(f'📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней')
logger.info('📅 СМЕНА тарифа: срок начинается с текущей даты + дней', total_days=total_days)
elif subscription.end_date > current_time:
# Подписка активна - просто добавляем дни к текущей дате окончания
# БЕЗ бонусных дней (они уже учтены в end_date)
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f'📅 Подписка активна, добавляем {days} дней к текущей дате окончания')
logger.info('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days)
else:
# Подписка истекла - начинаем с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
@@ -403,14 +396,19 @@ async def extend_subscription(
pass
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f'📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней')
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', total_days=total_days)
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
# Логируем статус подписки перед проверкой
logger.info(f'🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}')
logger.info(
'🔄 Продление подписки текущий статус: дни',
subscription_id=subscription.id,
status=subscription.status,
days=days,
)
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
@@ -419,23 +417,21 @@ async def extend_subscription(
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(
'🔄 Статус подписки %s изменён с %s на ACTIVE',
subscription.id,
previous_status,
'🔄 Статус подписки изменён с на ACTIVE', subscription_id=subscription.id, previous_status=previous_status
)
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
logger.warning('⚠️ Попытка продлить PENDING подписку %s, дни: %s', subscription.id, days)
logger.warning('⚠️ Попытка продлить PENDING подписку , дни', subscription_id=subscription.id, days=days)
# Обновляем параметры тарифа, если переданы
if tariff_id is not None:
old_tariff_id = subscription.tariff_id
subscription.tariff_id = tariff_id
logger.info(f'📦 Обновлен тариф подписки: {old_tariff_id}{tariff_id}')
logger.info('📦 Обновлен тариф подписки: ', old_tariff_id=old_tariff_id, tariff_id=tariff_id)
# При покупке тарифа сбрасываем триальный статус
if subscription.is_trial:
subscription.is_trial = False
logger.info(f'🎓 Подписка {subscription.id} конвертирована из триала в платную')
logger.info('🎓 Подписка конвертирована из триала в платную', subscription_id=subscription.id)
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
@@ -452,14 +448,19 @@ async def extend_subscription(
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
'📊 Обновлен лимит трафика: ГБ → ГБ (смена тарифа, докупки сброшены)',
old_traffic=old_traffic,
traffic_limit_gb=traffic_limit_gb,
)
else:
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
purchased = subscription.purchased_traffic_gb or 0
subscription.traffic_limit_gb = traffic_limit_gb + purchased
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
'📊 Обновлен лимит трафика: ГБ → ГБ (докупки сохранены: ГБ)',
old_traffic=old_traffic,
traffic_limit_gb=traffic_limit_gb + purchased,
purchased=purchased,
)
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
@@ -475,12 +476,12 @@ async def extend_subscription(
if device_limit is not None:
old_devices = subscription.device_limit
subscription.device_limit = device_limit
logger.info(f'📱 Обновлен лимит устройств: {old_devices}{device_limit}')
logger.info('📱 Обновлен лимит устройств: ', old_devices=old_devices, device_limit=device_limit)
if connected_squads is not None:
old_squads = subscription.connected_squads
subscription.connected_squads = connected_squads
logger.info(f'🌍 Обновлены сквады: {old_squads}{connected_squads}')
logger.info('🌍 Обновлены сквады: ', old_squads=old_squads, connected_squads=connected_squads)
# Обработка daily полей при смене тарифа
if is_tariff_change and tariff_id is not None:
@@ -514,7 +515,11 @@ async def extend_subscription(
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f'🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ')
logger.info(
'🔄 Сброс трафика при продлении (fixed_with_topup): ГБ → ГБ',
old_limit=old_limit,
fixed_limit=fixed_limit,
)
subscription.updated_at = current_time
@@ -522,8 +527,8 @@ async def extend_subscription(
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
logger.info(f'✅ Подписка продлена до: {subscription.end_date}')
logger.info(f'📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}')
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
return subscription
@@ -570,7 +575,10 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
await db.refresh(subscription)
logger.info(
f'📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime("%d.%m.%Y")})'
'📈 К подписке пользователя добавлено ГБ трафика (истекает )',
user_id=subscription.user_id,
gb=gb,
new_expires_at=new_expires_at.strftime('%d.%m.%Y'),
)
return subscription
@@ -582,7 +590,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
await db.commit()
await db.refresh(subscription)
logger.info(f'📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств')
logger.info('📱 К подписке пользователя добавлено устройств', user_id=subscription.user_id, devices=devices)
return subscription
@@ -594,7 +602,7 @@ async def add_subscription_squad(db: AsyncSession, subscription: Subscription, s
await db.commit()
await db.refresh(subscription)
logger.info(f'🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}')
logger.info('🌍 К подписке пользователя добавлен сквад', user_id=subscription.user_id, squad_uuid=squad_uuid)
return subscription
@@ -609,7 +617,7 @@ async def remove_subscription_squad(db: AsyncSession, subscription: Subscription
await db.commit()
await db.refresh(subscription)
logger.info(f'🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}')
logger.info('🚫 Из подписки пользователя удален сквад', user_id=subscription.user_id, squad_uuid=squad_uuid)
return subscription
@@ -639,11 +647,7 @@ async def decrement_subscription_server_counts(
ids_from_links = await get_subscription_server_ids(db, sub_id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
sub_id,
error,
)
logger.error('⚠️ Не удалось получить серверы подписки для уменьшения счетчика', sub_id=sub_id, error=error)
connected_squads = list(subscription.connected_squads or [])
if connected_squads:
@@ -653,11 +657,7 @@ async def decrement_subscription_server_counts(
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
server_ids.update(squad_server_ids)
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
sub_id,
error,
)
logger.error('⚠️ Не удалось сопоставить сквады подписки с серверами', sub_id=sub_id, error=error)
if not server_ids:
return
@@ -670,16 +670,16 @@ async def decrement_subscription_server_counts(
await remove_user_from_servers(db, list(server_ids))
except StaleDataError:
logger.warning(
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
sub_id,
list(server_ids),
'⚠️ Подписка уже удалена (StaleDataError), пропускаем декремент серверов',
sub_id=sub_id,
list=list(server_ids),
)
except Exception as error:
logger.error(
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
list(server_ids),
sub_id,
error,
'⚠️ Ошибка уменьшения счетчика пользователей серверов для подписки',
list=list(server_ids),
sub_id=sub_id,
error=error,
)
@@ -694,7 +694,7 @@ async def update_subscription_autopay(
await db.refresh(subscription)
status = 'включен' if enabled else 'выключен'
logger.info(f'💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}')
logger.info('💳 Автоплатеж для подписки пользователя', user_id=subscription.user_id, status=status)
return subscription
@@ -705,7 +705,7 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f'❌ Подписка пользователя {subscription.user_id} деактивирована')
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
return subscription
@@ -844,9 +844,9 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
renewals_count = conversion_stats.get('month_conversions', 0)
logger.info('📊 Статистика конверсии из таблицы conversions:')
logger.info(f' Общее количество конверсий: {conversion_stats.get("total_conversions", 0)}')
logger.info(f' Процент конверсии: {trial_to_paid_conversion}%')
logger.info(f' Конверсий за месяц: {renewals_count}')
logger.info('Общее количество конверсий', get=conversion_stats.get('total_conversions', 0))
logger.info('Процент конверсии', trial_to_paid_conversion=trial_to_paid_conversion)
logger.info('Конверсий за месяц', renewals_count=renewals_count)
except ImportError:
logger.warning('⚠️ Таблица subscription_conversions не найдена, используем старую логику')
@@ -943,9 +943,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
'Не удалось обновить счётчики серверов при сбросе триала %s: %s',
subscription.id,
error,
'Не удалось обновить счётчики серверов при сбросе триала', subscription_id=subscription.id, error=error
)
subscription_ids = [subscription.id for subscription in subscriptions]
@@ -954,11 +952,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
try:
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id.in_(subscription_ids)))
except Exception as error: # pragma: no cover - defensive logging
logger.error(
'Ошибка удаления серверных связей триалов %s: %s',
subscription_ids,
error,
)
logger.error('Ошибка удаления серверных связей триалов', subscription_ids=subscription_ids, error=error)
raise
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
@@ -967,10 +961,10 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
await db.commit()
except Exception as error: # pragma: no cover - defensive logging
await db.rollback()
logger.error('Ошибка сохранения сброса триалов: %s', error)
logger.error('Ошибка сохранения сброса триалов', error=error)
raise
logger.info('♻️ Сброшено триальных подписок: %s', reset_count)
logger.info('♻️ Сброшено триальных подписок', reset_count=reset_count)
return reset_count
@@ -1047,7 +1041,12 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f'🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}')
logger.info(
'🌐 К подписке добавлено серверов с ценами',
subscription_id=subscription.id,
server_squad_ids_count=len(server_squad_ids),
paid_prices=paid_prices,
)
return subscription
@@ -1084,7 +1083,7 @@ async def get_servers_monthly_prices(
user_promo_group = user.get_primary_promo_group()
user_promo_group_id = user_promo_group.id if user_promo_group else None
except Exception as e:
logger.warning(f'Не удалось получить промогруппу пользователя: {e}')
logger.warning('Не удалось получить промогруппу пользователя', error=e)
for server_id in server_squad_ids:
# Загружаем сервер с промогруппами
@@ -1110,9 +1109,11 @@ async def get_servers_monthly_prices(
else:
# Сервер недоступен для промогруппы пользователя
logger.warning(
f'⚠️ Сервер {server.display_name} (id={server_id}) недоступен для '
f'промогруппы пользователя (promo_group_id={user_promo_group_id}), '
f'allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}'
'⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups',
display_name=server.display_name,
server_id=server_id,
user_promo_group_id=user_promo_group_id,
value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [],
)
prices.append(server.price_kopeks) # Всё равно берём реальную цену
@@ -1227,8 +1228,10 @@ async def calculate_subscription_total_cost(
],
}
logger.debug(f'📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):')
logger.debug(f' Базовый период: {base_price / 100}')
logger.debug(
'📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period
)
logger.debug('Базовый период: ₽', base_price=base_price / 100)
if total_traffic_price > 0:
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}'
if total_traffic_discount > 0:
@@ -1248,7 +1251,7 @@ async def calculate_subscription_total_cost(
if total_devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
logger.debug(message)
logger.debug(f' ИТОГО: {total_cost / 100}')
logger.debug('ИТОГО: ₽', total_cost=total_cost / 100)
return total_cost, details
@@ -1300,11 +1303,11 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
)
await db.commit()
logger.info(f'🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}')
logger.info('🗑️ Удалены серверы из подписки', server_squad_ids=server_squad_ids, subscription_id=subscription_id)
return True
except Exception as e:
logger.error(f'Ошибка удаления серверов из подписки: {e}')
logger.error('Ошибка удаления серверов из подписки', error=e)
await db.rollback()
return False
@@ -1395,8 +1398,13 @@ async def get_subscription_renewal_cost(
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(f'💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):')
logger.info(f' 📅 Период: {base_price / 100}')
logger.info(
'💰 Расчет продления подписки на дней ( мес)',
subscription_id=subscription_id,
period_days=period_days,
months_in_period=months_in_period,
)
logger.info('📅 Период: ₽', base_price=base_price / 100)
if total_servers_cost > 0:
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}'
if total_servers_discount > 0:
@@ -1414,12 +1422,12 @@ async def get_subscription_renewal_cost(
if total_devices_discount > 0:
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
logger.info(message)
logger.info(f' 💎 ИТОГО: {total_cost / 100}')
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
return total_cost
except Exception as e:
logger.error(f'Ошибка расчета стоимости продления: {e}')
logger.error('Ошибка расчета стоимости продления', error=e)
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
@@ -1508,7 +1516,7 @@ async def calculate_addon_cost_for_remaining_period(
)
logger.info(message)
logger.info(f'💰 Итого доплата за {months_to_pay} мес: {total_cost / 100}')
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
return total_cost
@@ -1519,7 +1527,7 @@ async def expire_subscription(db: AsyncSession, subscription: Subscription) -> S
await db.commit()
await db.refresh(subscription)
logger.info(f'⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая')
logger.info('⏰ Подписка пользователя помечена как истёкшая', user_id=subscription.user_id)
return subscription
@@ -1527,28 +1535,30 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S
current_time = datetime.utcnow()
logger.info(
'🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s',
subscription.id,
subscription.status,
format_local_datetime(subscription.end_date),
format_local_datetime(current_time),
'🔍 Проверка статуса подписки , текущий статус дата окончания текущее время',
subscription_id=subscription.id,
subscription_status=subscription.status,
format_local_datetime=format_local_datetime(subscription.end_date),
format_local_datetime_2=format_local_datetime(current_time),
)
# Для суточных тарифов с паузой не меняем статус на expired
# (время "заморожено" пока пользователь на паузе)
is_daily_paused = getattr(subscription, 'is_daily_paused', False)
if is_daily_paused:
logger.info(f'⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения')
logger.info('⏸️ Суточная подписка на паузе, пропускаем проверку истечения', subscription_id=subscription.id)
return subscription
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
# Детальное логирование для отладки проблемы с деактивацией
time_diff = current_time - subscription.end_date
logger.warning(
f'⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) '
f'деактивируется в check_and_update_subscription_status. '
f'end_date={subscription.end_date}, current_time={current_time}, '
f'просрочена на {time_diff}'
'⏰ DEACTIVATION: подписка (user_id=) деактивируется в check_and_update_subscription_status. end_date=, current_time=, просрочена на',
subscription_id=subscription.id,
user_id=subscription.user_id,
end_date=subscription.end_date,
current_time=current_time,
time_diff=time_diff,
)
subscription.status = SubscriptionStatus.EXPIRED.value
@@ -1557,9 +1567,9 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S
await db.commit()
await db.refresh(subscription)
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
logger.info("⏰ Статус подписки пользователя изменен на 'expired'", user_id=subscription.user_id)
elif subscription.status == SubscriptionStatus.PENDING.value:
logger.info(f'ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений')
logger.info('ℹ️ Проверка PENDING подписки статус остается без изменений', subscription_id=subscription.id)
return subscription
@@ -1614,7 +1624,7 @@ async def create_subscription_no_commit(
await db.flush()
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f'✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)')
logger.info('✅ Подготовлена подписка для пользователя (ожидает коммита)', user_id=user_id)
return subscription
@@ -1662,7 +1672,7 @@ async def create_subscription(
await db.commit()
await db.refresh(subscription)
logger.info(f'✅ Создана подписка для пользователя {user_id}')
logger.info('✅ Создана подписка для пользователя', user_id=user_id)
return subscription
@@ -1694,9 +1704,9 @@ async def create_pending_subscription(
and existing_subscription.end_date > current_time
):
logger.warning(
'⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.',
trial_label,
user_id,
'⚠️ Попытка создать pending подписку для активного пользователя . Возвращаем существующую запись.',
trial_label=trial_label,
user_id=user_id,
)
return existing_subscription
@@ -1714,11 +1724,11 @@ async def create_pending_subscription(
await db.refresh(existing_subscription)
logger.info(
'♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s',
trial_label,
user_id,
existing_subscription.id,
payment_method,
'♻️ Обновлена ожидающая подписка пользователя , ID метод оплаты',
trial_label=trial_label,
user_id=user_id,
existing_subscription_id=existing_subscription.id,
payment_method=payment_method,
)
return existing_subscription
@@ -1740,11 +1750,11 @@ async def create_pending_subscription(
await db.refresh(subscription)
logger.info(
'💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s',
trial_label,
user_id,
subscription.id,
payment_method,
'💳 Создана ожидающая подписка для пользователя , ID метод оплаты',
trial_label=trial_label,
user_id=user_id,
subscription_id=subscription.id,
payment_method=payment_method,
)
return subscription
@@ -1777,7 +1787,7 @@ async def create_pending_trial_subscription(
async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None:
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
logger.info(f'Активация pending подписки: пользователь {user_id}, период {period_days} дней')
logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days)
# Находим pending подписку пользователя
result = await db.execute(
@@ -1788,11 +1798,14 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f'Не найдена pending подписка для пользователя {user_id}')
logger.warning('Не найдена pending подписка для пользователя', user_id=user_id)
return None
logger.info(
f'Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}'
'Найдена pending подписка для пользователя статус',
pending_subscription_id=pending_subscription.id,
user_id=user_id,
status=pending_subscription.status,
)
# Обновляем статус подписки на ACTIVE
@@ -1812,7 +1825,9 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
await db.commit()
await db.refresh(pending_subscription)
logger.info(f'Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}')
logger.info(
'Подписка пользователя активирована, ID', user_id=user_id, pending_subscription_id=pending_subscription.id
)
return pending_subscription
@@ -1823,7 +1838,11 @@ async def activate_pending_trial_subscription(
user_id: int,
) -> Subscription | None:
"""Активирует pending триальную подписку по её ID после оплаты."""
logger.info(f'Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}')
logger.info(
'Активация pending триальной подписки: subscription_id=, user_id',
subscription_id=subscription_id,
user_id=user_id,
)
# Находим pending подписку по ID
result = await db.execute(
@@ -1839,10 +1858,16 @@ async def activate_pending_trial_subscription(
pending_subscription = result.scalar_one_or_none()
if not pending_subscription:
logger.warning(f'Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}')
logger.warning(
'Не найдена pending триальная подписка для пользователя', subscription_id=subscription_id, user_id=user_id
)
return None
logger.info(f'Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}')
logger.info(
'Найдена pending триальная подписка статус',
pending_subscription_id=pending_subscription.id,
status=pending_subscription.status,
)
# Обновляем статус подписки на ACTIVE
current_time = datetime.utcnow()
@@ -1865,7 +1890,11 @@ async def activate_pending_trial_subscription(
await db.commit()
await db.refresh(pending_subscription)
logger.info(f'Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}')
logger.info(
'Триальная подписка активирована для пользователя',
pending_subscription_id=pending_subscription.id,
user_id=user_id,
)
return pending_subscription
@@ -1911,7 +1940,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
result = await db.execute(query)
subscriptions = result.scalars().all()
logger.info(f'🔍 Найдено {len(subscriptions)} суточных подписок для списания')
logger.info('🔍 Найдено суточных подписок для списания', subscriptions_count=len(subscriptions))
return list(subscriptions)
@@ -1948,7 +1977,7 @@ async def get_disabled_daily_subscriptions_for_resume(
result = await db.execute(query)
subscriptions = result.scalars().all()
logger.info(f'🔍 Найдено {len(subscriptions)} DISABLED суточных подписок для возобновления')
logger.info('🔍 Найдено DISABLED суточных подписок для возобновления', subscriptions_count=len(subscriptions))
return list(subscriptions)
@@ -1959,14 +1988,18 @@ async def pause_daily_subscription(
) -> Subscription:
"""Приостанавливает суточную подписку (списание не будет происходить)."""
if not subscription.is_daily_tariff:
logger.warning(f'Попытка приостановить не-суточную подписку {subscription.id}')
logger.warning('Попытка приостановить не-суточную подписку', subscription_id=subscription.id)
return subscription
subscription.is_daily_paused = True
await db.commit()
await db.refresh(subscription)
logger.info(f'⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}')
logger.info(
'⏸️ Суточная подписка приостановлена пользователем',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return subscription
@@ -1977,23 +2010,30 @@ async def resume_daily_subscription(
) -> Subscription:
"""Возобновляет суточную подписку (списание продолжится)."""
if not subscription.is_daily_tariff:
logger.warning(f'Попытка возобновить не-суточную подписку {subscription.id}')
logger.warning('Попытка возобновить не-суточную подписку', subscription_id=subscription.id)
return subscription
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
logger.info(
'✅ Суточная подписка восстановлена из в ACTIVE',
subscription_id=subscription.id,
previous_status=previous_status,
)
await db.commit()
await db.refresh(subscription)
logger.info(f'▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}')
logger.info(
'▶️ Суточная подписка возобновлена пользователем', subscription_id=subscription.id, user_id=subscription.user_id
)
return subscription
@@ -2011,7 +2051,7 @@ async def update_daily_charge_time(
new_end_date = now + timedelta(days=1)
if subscription.end_date is None or subscription.end_date < new_end_date:
subscription.end_date = new_end_date
logger.info(f'📅 Продлена подписка {subscription.id} до {new_end_date}')
logger.info('📅 Продлена подписка до', subscription_id=subscription.id, new_end_date=new_end_date)
await db.commit()
await db.refresh(subscription)
@@ -2032,7 +2072,9 @@ async def suspend_daily_subscription_insufficient_balance(
await db.refresh(subscription)
logger.info(
f'⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})'
'⚠️ Суточная подписка приостановлена: недостаточно средств (user_id=)',
subscription_id=subscription.id,
user_id=subscription.user_id,
)
return subscription
+10 -6
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime, timedelta
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionConversion, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_subscription_conversion(
@@ -32,7 +32,11 @@ async def create_subscription_conversion(
await db.refresh(conversion)
logger.info(
f'✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks / 100}'
'✅ Создана запись о конверсии для пользователя дн. → дн. за ',
user_id=user_id,
trial_duration_days=trial_duration_days,
first_paid_period_days=first_paid_period_days,
first_payment_amount_kopeks=first_payment_amount_kopeks / 100,
)
return conversion
@@ -85,9 +89,9 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
month_conversions = month_conversions_result.scalar() or 0
logger.info('📊 Статистика конверсий:')
logger.info(f' Всего пользователей с подписками: {total_users_with_subscriptions}')
logger.info(f' Оплативших подписку: {users_with_paid}')
logger.info(f' Рассчитанная конверсия: {conversion_rate}%')
logger.info('Всего пользователей с подписками', total_users_with_subscriptions=total_users_with_subscriptions)
logger.info('Оплативших подписку', users_with_paid=users_with_paid)
logger.info('Рассчитанная конверсия', conversion_rate=conversion_rate)
return {
'total_conversions': total_conversions,
+22 -26
View File
@@ -1,5 +1,4 @@
import logging
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -7,7 +6,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, Subscription, Tariff
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
def _normalize_period_prices(period_prices: dict[int, int] | None) -> dict[str, int]:
@@ -241,13 +240,13 @@ async def create_tariff(
await db.refresh(tariff)
logger.info(
"Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)",
tariff.name,
tariff.id,
tariff.tier_level,
tariff.traffic_limit_gb,
tariff.device_limit,
normalized_prices,
"Создан тариф '' (id tier traffic=GB, devices prices=)",
tariff_name=tariff.name,
tariff_id=tariff.id,
tier_level=tariff.tier_level,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
normalized_prices=normalized_prices,
)
return tariff
@@ -365,11 +364,7 @@ async def update_tariff(
await db.commit()
await db.refresh(tariff)
logger.info(
"Обновлен тариф '%s' (id=%s)",
tariff.name,
tariff.id,
)
logger.info("Обновлен тариф '' (id=)", tariff_name=tariff.name, tariff_id=tariff.id)
return tariff
@@ -393,10 +388,10 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
await db.commit()
logger.info(
"Удален тариф '%s' (id=%s), затронуто подписок: %s",
tariff_name,
tariff_id,
affected_subscriptions,
"Удален тариф '' (id=), затронуто подписок",
tariff_name=tariff_name,
tariff_id=tariff_id,
affected_subscriptions=affected_subscriptions,
)
return True
@@ -486,7 +481,7 @@ async def reorder_tariffs(
for order, tariff_id in enumerate(tariff_order):
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
logger.info('Изменен порядок тарифов: %s', tariff_order)
logger.info('Изменен порядок тарифов', tariff_order=tariff_order)
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
@@ -522,7 +517,8 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
# Админ управляет тарифом через кабинет, синхронизация не нужна.
logger.info(
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
"Дефолтный тариф 'Стандартный' (id=) уже существует, пропускаем sync из конфига",
existing_tariff_id=existing_tariff.id,
)
return existing_tariff
@@ -544,7 +540,7 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
db.add(new_tariff)
await db.commit()
await db.refresh(new_tariff)
logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices)
logger.info("Создан дефолтный тариф 'Стандартный' из конфига", period_prices=period_prices)
return new_tariff
return None
@@ -574,7 +570,7 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
return
if not tariff.period_prices:
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
logger.warning("Тариф '' (id=) найден, но period_prices пуст", tariff_name=tariff.name, tariff_id=tariff.id)
return
# Преобразуем строковые ключи в int
@@ -588,10 +584,10 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
{f'{d}д': f'{p // 100}' for d, p in period_prices.items()},
)
else:
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
logger.warning("Тариф '' не имеет активных периодов (все цены = 0)", tariff_name=tariff.name)
except Exception as e:
logger.error('Ошибка загрузки периодов из БД: %s', e)
logger.error('Ошибка загрузки периодов из БД', e=e)
async def ensure_tariffs_synced(db: AsyncSession) -> None:
@@ -604,4 +600,4 @@ async def ensure_tariffs_synced(db: AsyncSession) -> None:
# Загружаем периоды из БД в PERIOD_PRICES
await load_period_prices_from_db(db)
except Exception as e:
logger.error('Ошибка синхронизации тарифов: %s', e)
logger.error('Ошибка синхронизации тарифов', e=e)
+5 -5
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import SupportAuditLog, Ticket, TicketMessage, TicketStatus
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class TicketCRUD:
@@ -64,7 +64,7 @@ class TicketCRUD:
db=db,
)
except Exception as error:
logger.warning('Failed to emit ticket.created event: %s', error)
logger.warning('Failed to emit ticket.created event', error=error)
return ticket
@@ -248,7 +248,7 @@ class TicketCRUD:
db=db,
)
except Exception as error:
logger.warning('Failed to emit ticket.status_changed event: %s', error)
logger.warning('Failed to emit ticket.status_changed event', error=error)
return True
@@ -440,7 +440,7 @@ class TicketMessageCRUD:
db=db,
)
except Exception as error:
logger.warning('Failed to emit ticket.message_added event: %s', error)
logger.warning('Failed to emit ticket.message_added event', error=error)
return message
+2 -2
View File
@@ -1,8 +1,8 @@
"""CRUD operations for TicketNotification."""
import logging
from datetime import datetime
import structlog
from sqlalchemy import desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import Ticket, TicketNotification
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class TicketNotificationCRUD:
+15 -20
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timedelta
import structlog
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PaymentMethod, Transaction, TransactionType, User
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
# Реальные платёжные методы для подсчёта дохода
# Исключены: MANUAL (админские), BALANCE (оплата с баланса), NULL (колесо, промокоды, бонусы)
@@ -55,7 +55,12 @@ async def create_transaction(
await db.commit()
await db.refresh(transaction)
logger.info(f'💳 Создана транзакция: {type.value} на {amount_kopeks / 100}₽ для пользователя {user_id}')
logger.info(
'💳 Создана транзакция: на ₽ для пользователя',
type_value=type.value,
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
)
# Отправляем событие о транзакции
try:
@@ -77,7 +82,7 @@ async def create_transaction(
db=db,
)
except Exception as error:
logger.warning('Failed to emit transaction event: %s', error)
logger.warning('Failed to emit transaction event', error=error)
try:
from app.services.promo_group_assignment import (
@@ -86,11 +91,7 @@ async def create_transaction(
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug(
'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s',
user_id,
exc,
)
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
if type == TransactionType.SUBSCRIPTION_PAYMENT:
try:
from app.services.referral_contest_service import referral_contest_service
@@ -101,11 +102,7 @@ async def create_transaction(
amount_kopeks,
)
except Exception as exc:
logger.debug(
'Не удалось записать событие конкурса для пользователя %s: %s',
user_id,
exc,
)
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
return transaction
@@ -171,7 +168,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
await db.commit()
await db.refresh(transaction)
logger.info(f'✅ Транзакция {transaction.id} завершена')
logger.info('✅ Транзакция завершена', transaction_id=transaction.id)
try:
from app.services.promo_group_assignment import (
@@ -181,9 +178,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
except Exception as exc:
logger.debug(
'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s',
transaction.user_id,
exc,
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
)
return transaction
@@ -392,7 +387,7 @@ async def check_tribute_payment_duplicate(
transaction = result.scalar_one_or_none()
if transaction:
logger.info(f'🔍 Найден дубликат платежа в течение 24ч: {transaction.id}')
logger.info('🔍 Найден дубликат платежа в течение 24ч', transaction_id=transaction.id)
return transaction
@@ -408,7 +403,7 @@ async def create_unique_tribute_transaction(
timestamp = int(datetime.utcnow().timestamp())
external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}'
logger.info(f'Создан уникальный external_id для избежания дубликатов: {external_id}')
logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id)
return await create_transaction(
db=db,
+55 -52
View File
@@ -1,8 +1,8 @@
import logging
import secrets
import string
from datetime import UTC, datetime, timedelta
import structlog
from sqlalchemy import and_, case, func, nullslast, or_, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,7 +25,7 @@ from app.database.models import (
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
@@ -269,7 +269,11 @@ async def create_user_no_commit(
user.promo_group = default_group
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f'✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)')
logger.info(
'✅ Подготовлен пользователь с реферальным кодом (ожидает коммита)',
telegram_id=telegram_id,
referral_code=referral_code,
)
return user
@@ -316,7 +320,9 @@ async def create_user(
await db.refresh(user)
user.promo_group = default_group
logger.info(f'✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}')
logger.info(
'✅ Создан пользователь с реферальным кодом', telegram_id=telegram_id, referral_code=referral_code
)
# Отправляем событие о создании пользователя
try:
@@ -336,7 +342,7 @@ async def create_user(
db=db,
)
except Exception as error:
logger.warning('Failed to emit user.created event: %s', error)
logger.warning('Failed to emit user.created event', error=error)
return user
@@ -349,11 +355,10 @@ async def create_user(
and attempt < attempts
):
logger.warning(
'⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. '
'Выполняем повторную синхронизацию (попытка %s/%s)',
telegram_id,
attempt,
attempts,
'⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя . Выполняем повторную синхронизацию (попытка /)',
telegram_id=telegram_id,
attempt=attempt,
attempts=attempts,
)
await _sync_users_sequence(db)
continue
@@ -413,7 +418,11 @@ async def add_user_balance(
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
f'💰 Баланс пользователя {user_id_display} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})'
'💰 Баланс пользователя изменен: → (изменение: +)',
user_id_display=user_id_display,
old_balance=old_balance,
balance_kopeks=user.balance_kopeks,
amount_kopeks=amount_kopeks,
)
# Автоматическое возобновление приостановленной суточной подписки
@@ -436,8 +445,9 @@ async def add_user_balance(
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
f'после пополнения баланса (user_id={user.id})'
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
subscription_id=subscription.id,
user_id=user.id,
)
# Синхронизируем с RemnaWave
try:
@@ -446,14 +456,14 @@ async def add_user_balance(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
except Exception as resume_err:
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
return True
except Exception as e:
logger.error(f'Ошибка изменения баланса пользователя {user.id}: {e}')
logger.error('Ошибка изменения баланса пользователя', user_id=user.id, error=e)
await db.rollback()
return False
@@ -469,7 +479,7 @@ async def add_user_balance_by_id(
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f'Пользователь с telegram_id {telegram_id} не найден')
logger.error('Пользователь с telegram_id не найден', telegram_id=telegram_id)
return False
return await add_user_balance(
@@ -482,7 +492,7 @@ async def add_user_balance_by_id(
)
except Exception as e:
logger.error(f'Ошибка пополнения баланса пользователя {telegram_id}: {e}')
logger.error('Ошибка пополнения баланса пользователя', telegram_id=telegram_id, error=e)
return False
@@ -498,10 +508,10 @@ async def subtract_user_balance(
) -> bool:
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info('💸 ОТЛАДКА subtract_user_balance:')
logger.info(f' 👤 User ID: {user.id} (ID: {user_id_display})')
logger.info(f' 💰 Баланс до списания: {user.balance_kopeks} копеек')
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
logger.info(f' 📝 Описание: {description}')
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
logger.info('📝 Описание', description=description)
# Lock the user row to prevent concurrent balance race conditions
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
@@ -531,9 +541,7 @@ async def subtract_user_balance(
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to fetch latest claimed promo offer for user %s: %s',
user.id,
lookup_error,
'Failed to fetch latest claimed promo offer for user', user_id=user.id, lookup_error=lookup_error
)
offer = None
@@ -592,23 +600,21 @@ async def subtract_user_balance(
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer consumption log for user %s: %s',
user.id,
log_error,
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer consumption log failure: %s',
rollback_error,
'Failed to rollback session after promo offer consumption log failure',
rollback_error=rollback_error,
)
logger.info(f' ✅ Средства списаны: {old_balance}{user.balance_kopeks}')
logger.info('✅ Средства списаны: ', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
return True
except Exception as e:
logger.error(f' ❌ ОШИБКА СПИСАНИЯ: {e}')
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
await db.rollback()
return False
@@ -643,9 +649,9 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s',
user.id,
lookup_error,
'Failed to fetch latest claimed promo offer for user during expiration cleanup',
user_id=user.id,
lookup_error=lookup_error,
)
offer = None
@@ -688,17 +694,12 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
details={'reason': 'offer_expired'},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to log promo offer expiration for user %s: %s',
user_id,
log_error,
)
logger.warning('Failed to log promo offer expiration for user', user_id=user_id, log_error=log_error)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer expiration log failure: %s',
rollback_error,
'Failed to rollback session after promo offer expiration log failure', rollback_error=rollback_error
)
return len(users)
@@ -944,7 +945,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
),
)
else:
logger.warning('Неизвестный сегмент для промо: %s', segment)
logger.warning('Неизвестный сегмент для промо', segment=segment)
return []
result = await db.execute(query.order_by(User.id))
@@ -989,7 +990,7 @@ async def delete_user(db: AsyncSession, user: User) -> bool:
await db.commit()
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(f'🗑️ Пользователь {user_id_display} помечен как удаленный')
logger.info('🗑️ Пользователь помечен как удаленный', user_id_display=user_id_display)
return True
@@ -1104,7 +1105,7 @@ async def create_user_by_email(
await db.refresh(user)
user.promo_group = default_group
logger.info(f'✅ Создан email-пользователь {email} с id={user.id}')
logger.info('✅ Создан email-пользователь с id', email=email, user_id=user.id)
# Emit event
try:
@@ -1123,7 +1124,7 @@ async def create_user_by_email(
db=db,
)
except Exception as error:
logger.warning('Failed to emit user.created event: %s', error)
logger.warning('Failed to emit user.created event', error=error)
return user
@@ -1181,7 +1182,7 @@ async def set_email_change_pending(
await db.commit()
await db.refresh(user)
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
logger.info('Email change pending for user', user_id=user.id, email=user.email, new_email=new_email)
return user
@@ -1235,7 +1236,7 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
await db.commit()
await db.refresh(user)
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
logger.info('Email changed for user', user_id=user.id, old_email=old_email, new_email=new_email)
return True, 'Email changed successfully'
@@ -1253,7 +1254,7 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f'Email change cancelled for user {user.id}')
logger.info('Email change cancelled for user', user_id=user.id)
# --- OAuth provider functions ---
@@ -1286,7 +1287,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str
value: str | int = int(provider_id) if provider == 'vk' else provider_id
setattr(user, column_name, value)
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}')
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
async def create_user_by_oauth(
@@ -1332,7 +1333,9 @@ async def create_user_by_oauth(
await db.refresh(user)
user.promo_group = default_group
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
logger.info(
'Created OAuth user via (provider_id=) with id', provider=provider, provider_id=provider_id, user_id=user.id
)
try:
from app.services.event_emitter import event_emitter
@@ -1349,6 +1352,6 @@ async def create_user_by_oauth(
db=db,
)
except Exception as error:
logger.warning('Failed to emit user.created event: %s', error)
logger.warning('Failed to emit user.created event', error=error)
return user
+6 -6
View File
@@ -1,7 +1,7 @@
import logging
import random
from datetime import datetime
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,7 +9,7 @@ from app.database.models import User, UserMessage
from app.utils.validators import sanitize_html, validate_html_tags
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_user_message(
@@ -36,7 +36,7 @@ async def create_user_message(
await db.commit()
await db.refresh(message)
logger.info(f'✅ Создано сообщение ID {message.id} пользователем {created_by}')
logger.info('✅ Создано сообщение ID пользователем', message_id=message.id, created_by=created_by)
return message
@@ -116,7 +116,7 @@ async def update_user_message(
await db.commit()
await db.refresh(message)
logger.info(f'📝 Обновлено сообщение ID {message_id}')
logger.info('📝 Обновлено сообщение ID', message_id=message_id)
return message
@@ -133,7 +133,7 @@ async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserM
await db.refresh(message)
status_text = 'активировано' if message.is_active else 'деактивировано'
logger.info(f'🔄 Сообщение ID {message_id} {status_text}')
logger.info('🔄 Сообщение ID', message_id=message_id, status_text=status_text)
return message
@@ -147,7 +147,7 @@ async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
await db.delete(message)
await db.commit()
logger.info(f'🗑️ Удалено сообщение ID {message_id}')
logger.info('🗑️ Удалено сообщение ID', message_id=message_id)
return True
+22 -19
View File
@@ -1,8 +1,8 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User, UserPromoGroup
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def _sync_user_primary_promo_group(
@@ -39,11 +39,7 @@ async def _sync_user_primary_promo_group(
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error(
'Ошибка синхронизации primary промогруппы пользователя %s: %s',
user_id,
error,
)
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
async def sync_user_primary_promo_group(
@@ -74,7 +70,7 @@ async def add_user_to_promo_group(
# Проверяем существование связи
existing = await has_user_promo_group(db, user_id, promo_group_id)
if existing:
logger.info(f'Пользователь {user_id} уже имеет промогруппу {promo_group_id}')
logger.info('Пользователь уже имеет промогруппу', user_id=user_id, promo_group_id=promo_group_id)
return None
# Создаем новую связь
@@ -91,11 +87,16 @@ async def add_user_to_promo_group(
await db.commit()
await db.refresh(user_promo_group)
logger.info(f'Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})')
logger.info(
'Пользователю добавлена промогруппа',
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by,
)
return user_promo_group
except Exception as error:
logger.error(f'Ошибка добавления промогруппы пользователю: {error}')
logger.error('Ошибка добавления промогруппы пользователю', error=error)
await db.rollback()
return None
@@ -121,7 +122,9 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
user_promo_group = result.scalar_one_or_none()
if not user_promo_group:
logger.warning(f'Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена')
logger.warning(
'Связь пользователя с промогруппой не найдена', user_id=user_id, promo_group_id=promo_group_id
)
return False
await db.delete(user_promo_group)
@@ -131,11 +134,11 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
await db.commit()
logger.info(f'У пользователя {user_id} удалена промогруппа {promo_group_id}')
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
return True
except Exception as error:
logger.error(f'Ошибка удаления промогруппы у пользователя: {error}')
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
await db.rollback()
return False
@@ -162,7 +165,7 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
return list(result.scalars().all())
except Exception as error:
logger.error(f'Ошибка получения промогрупп пользователя {user_id}: {error}')
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
return []
@@ -187,7 +190,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
except Exception as error:
logger.error(f'Ошибка получения primary промогруппы пользователя {user_id}: {error}')
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
return None
@@ -212,7 +215,7 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error(f'Ошибка проверки промогруппы пользователя: {error}')
logger.error('Ошибка проверки промогруппы пользователя', error=error)
return False
@@ -232,7 +235,7 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
return len(list(result.scalars().all()))
except Exception as error:
logger.error(f'Ошибка подсчета промогрупп пользователя: {error}')
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
return 0
@@ -264,10 +267,10 @@ async def replace_user_promo_groups(
db.add(user_promo_group)
await db.commit()
logger.info(f'Промогруппы пользователя {user_id} заменены на {promo_group_ids}')
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
return True
except Exception as error:
logger.error(f'Ошибка замены промогрупп пользователя: {error}')
logger.error('Ошибка замены промогрупп пользователя', error=error)
await db.rollback()
return False
+12 -14
View File
@@ -1,16 +1,16 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WataPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_wata_payment(
@@ -53,11 +53,11 @@ async def create_wata_payment(
await db.refresh(payment)
logger.info(
'Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)',
payment.id,
user_id,
amount_kopeks,
status,
'Создан Wata платеж # для пользователя : копеек (статус)',
payment_id=payment.id,
user_id=user_id,
amount_kopeks=amount_kopeks,
status=status,
)
return payment
@@ -128,10 +128,10 @@ async def update_wata_payment_status(
await db.refresh(payment)
logger.info(
'Обновлен Wata платеж %s: статус=%s, is_paid=%s',
payment.payment_link_id,
payment.status,
payment.is_paid,
'Обновлен Wata платеж : статус is_paid',
payment_link_id=payment.payment_link_id,
payment_status=payment.status,
is_paid=payment.is_paid,
)
return payment
@@ -147,9 +147,7 @@ async def link_wata_payment_to_transaction(
await db.refresh(payment)
logger.info(
'Wata платеж %s привязан к транзакции %s',
payment.payment_link_id,
transaction_id,
'Wata платеж привязан к транзакции', payment_link_id=payment.payment_link_id, transaction_id=transaction_id
)
return payment
+16 -16
View File
@@ -1,13 +1,13 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, WelcomeText
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
WELCOME_TEXT_KEY = 'welcome_text'
@@ -83,7 +83,7 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
await db.refresh(welcome_text)
status = 'включен' if welcome_text.is_enabled else 'отключен'
logger.info(f'Приветственный текст {status} администратором {admin_id}')
logger.info('Приветственный текст администратором', status=status, admin_id=admin_id)
return welcome_text.is_enabled
default_text = await get_current_welcome_text_or_default()
new_welcome_text = WelcomeText(text_content=default_text, is_active=True, is_enabled=True, created_by=admin_id)
@@ -92,11 +92,11 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
await db.commit()
await db.refresh(new_welcome_text)
logger.info(f'Создан и включен дефолтный приветственный текст администратором {admin_id}')
logger.info('Создан и включен дефолтный приветственный текст администратором', admin_id=admin_id)
return True
except Exception as e:
logger.error(f'Ошибка при переключении статуса приветственного текста: {e}')
logger.error('Ошибка при переключении статуса приветственного текста', error=e)
await db.rollback()
return False
@@ -116,11 +116,11 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
await db.commit()
await db.refresh(new_welcome_text)
logger.info(f'Установлен новый приветственный текст администратором {admin_id}')
logger.info('Установлен новый приветственный текст администратором', admin_id=admin_id)
return True
except Exception as e:
logger.error(f'Ошибка при установке приветственного текста: {e}')
logger.error('Ошибка при установке приветственного текста', error=e)
await db.rollback()
return False
@@ -154,10 +154,10 @@ async def create_welcome_text(
await db.refresh(welcome_text)
logger.info(
'✅ Создан приветственный текст ID %s (активный=%s, включен=%s)',
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
'✅ Создан приветственный текст ID (активный включен=)',
welcome_text_id=welcome_text.id,
is_active=welcome_text.is_active,
is_enabled=welcome_text.is_enabled,
)
return welcome_text
@@ -188,10 +188,10 @@ async def update_welcome_text(
await db.refresh(welcome_text)
logger.info(
'📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)',
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
'📝 Обновлен приветственный текст ID (активный включен=)',
welcome_text_id=welcome_text.id,
is_active=welcome_text.is_active,
is_enabled=welcome_text.is_enabled,
)
return welcome_text
@@ -199,7 +199,7 @@ async def update_welcome_text(
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
await db.delete(welcome_text)
await db.commit()
logger.info('🗑️ Удален приветственный текст ID %s', welcome_text.id)
logger.info('🗑️ Удален приветственный текст ID', welcome_text_id=welcome_text.id)
async def get_current_welcome_text_or_default() -> str:
+8 -8
View File
@@ -2,10 +2,10 @@
CRUD операции для колеса удачи (Fortune Wheel).
"""
import logging
from datetime import datetime
from typing import Any
import structlog
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -17,7 +17,7 @@ from app.database.models import (
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
# ==================== WHEEL CONFIG ====================
@@ -68,7 +68,7 @@ async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig:
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
logger.info(f'🎡 Обновлена конфигурация колеса: {kwargs}')
logger.info('🎡 Обновлена конфигурация колеса', kwargs=kwargs)
return config
@@ -129,7 +129,7 @@ async def create_wheel_prize(
db.add(prize)
await db.commit()
await db.refresh(prize)
logger.info(f'🎁 Создан приз колеса: {display_name} ({prize_type})')
logger.info('🎁 Создан приз колеса', display_name=display_name, prize_type=prize_type)
return prize
@@ -146,7 +146,7 @@ async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> Wheel
prize.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(prize)
logger.info(f'🎁 Обновлен приз колеса ID={prize_id}: {kwargs}')
logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=kwargs)
return prize
@@ -158,7 +158,7 @@ async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool:
await db.delete(prize)
await db.commit()
logger.info(f'🗑️ Удален приз колеса ID={prize_id}')
logger.info('🗑️ Удален приз колеса ID', prize_id=prize_id)
return True
@@ -170,7 +170,7 @@ async def reorder_wheel_prizes(db: AsyncSession, prize_ids: list[int]) -> bool:
prize.sort_order = index
await db.commit()
logger.info(f'🔄 Переупорядочены призы колеса: {prize_ids}')
logger.info('🔄 Переупорядочены призы колеса', prize_ids=prize_ids)
return True
@@ -209,7 +209,7 @@ async def create_wheel_spin(
db.add(spin)
await db.commit()
await db.refresh(spin)
logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'")
logger.info('🎰 Создан спин колеса: user_id=, prize', user_id=user_id, prize_display_name=prize_display_name)
return spin
+24 -10
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
import structlog
from sqlalchemy import and_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
async def create_yookassa_payment(
@@ -46,15 +46,20 @@ async def create_yookassa_payment(
except IntegrityError as e:
await db.rollback()
logger.error(
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
yookassa_payment_id,
user_id,
e,
'FK violation при создании платежа YooKassa : user_id= не существует в БД',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
e=e,
)
return None
await db.refresh(payment)
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
logger.info(
'Создан платеж YooKassa: на ₽ для пользователя',
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
)
return payment
@@ -104,7 +109,12 @@ async def update_yookassa_payment_status(
payment = result.scalar_one_or_none()
if payment:
logger.info(f'Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}')
logger.info(
'Обновлен статус платежа YooKassa , paid',
yookassa_payment_id=yookassa_payment_id,
status=status,
is_paid=is_paid,
)
return payment
@@ -127,7 +137,11 @@ async def link_yookassa_payment_to_transaction(
payment = result.scalar_one_or_none()
if payment:
logger.info(f'Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}')
logger.info(
'Платеж YooKassa связан с транзакцией',
yookassa_payment_id=yookassa_payment_id,
transaction_id=transaction_id,
)
return payment
@@ -185,7 +199,7 @@ async def delete_yookassa_payment(db: AsyncSession, yookassa_payment_id: str) ->
if payment:
await db.delete(payment)
await db.commit()
logger.info(f'Удален платеж YooKassa: {yookassa_payment_id}')
logger.info('Удален платеж YooKassa', yookassa_payment_id=yookassa_payment_id)
return True
return False
+22 -24
View File
@@ -1,11 +1,11 @@
import asyncio
import logging
import time
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from functools import wraps
from typing import TypeVar
import structlog
from sqlalchemy import bindparam, event, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
@@ -16,7 +16,7 @@ from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
T = TypeVar('T')
@@ -126,16 +126,16 @@ def with_db_retry(
last_exception = e
if attempt < attempts:
logger.warning(
'Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...',
attempt,
attempts,
str(e)[:100],
current_delay,
'Ошибка БД (попытка /): . Повтор через сек...',
attempt=attempt,
attempts=attempts,
e=str(e)[:100],
current_delay=current_delay,
)
await asyncio.sleep(current_delay)
current_delay *= backoff
else:
logger.error('Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s', attempts, str(e))
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
raise last_exception
@@ -159,7 +159,7 @@ async def execute_with_retry(
except RETRYABLE_EXCEPTIONS as e:
last_exception = e
if attempt < attempts:
logger.warning('SQL retry (попытка %d/%d): %s', attempt, attempts, str(e)[:100])
logger.warning('SQL retry (попытка /)', attempt=attempt, attempts=attempts, e=str(e)[:100])
await asyncio.sleep(delay)
delay *= 2
@@ -175,15 +175,15 @@ if settings.DEBUG:
@event.listens_for(Engine, 'before_cursor_execute')
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault('query_start_time', []).append(time.time())
logger.debug(f'🔍 Executing query: {statement[:100]}...')
logger.debug('🔍 Executing query: ...', statement=statement[:100])
@event.listens_for(Engine, 'after_cursor_execute')
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info['query_start_time'].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f'🐌 Slow query ({total:.3f}s): {statement[:100]}...')
logger.warning('🐌 Slow query (s): ...', total=round(total, 3), statement=statement[:100])
else:
logger.debug(f'⚡ Query executed in {total:.3f}s')
logger.debug('⚡ Query executed in', total=round(total, 3))
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
@@ -201,7 +201,7 @@ def _validate_database_url(url: str | None) -> str | None:
return None
# Простая проверка на валидный формат
if not ('://' in url or url.startswith('sqlite')):
logger.warning('Невалидный DATABASE_URL: %s', url[:20])
logger.warning('Невалидный DATABASE_URL', url=url[:20])
return None
return url
@@ -234,9 +234,9 @@ class DatabaseManager:
expire_on_commit=False,
autoflush=False,
)
logger.info('Read replica настроена: %s', replica_url[:30] + '...')
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
except Exception as e:
logger.error('Не удалось настроить read replica: %s', e)
logger.error('Не удалось настроить read replica', e=e)
self.read_replica_engine = None
@asynccontextmanager
@@ -276,10 +276,10 @@ class DatabaseManager:
latency = (time.time() - start) * 1000
status = 'healthy'
except TimeoutError:
logger.error('Health check таймаут (%s сек)', timeout)
logger.error('Health check таймаут (сек)', timeout=timeout)
status = 'timeout'
except Exception as e:
logger.error('Database health check failed: %s', e)
logger.error('Database health check failed', e=e)
status = 'unhealthy'
return {
@@ -307,7 +307,7 @@ class DatabaseManager:
except TimeoutError:
status = 'timeout'
except Exception as e:
logger.error('Read replica health check failed: %s', e)
logger.error('Read replica health check failed', e=e)
return {
'status': status,
@@ -440,7 +440,7 @@ async def init_db():
# Продолжаем выполнение, так как основные таблицы могут быть созданы
else:
# Для других ошибок пробрасываем исключение
logger.error(f'❌ Ошибка при создании таблиц: {e}')
logger.error('❌ Ошибка при создании таблиц', error=e)
raise
if not IS_SQLITE:
@@ -468,21 +468,19 @@ async def init_db():
if not table_exists:
logger.debug(
'Пропускаем создание индекса %s: таблица %s отсутствует',
index_sql,
table_name,
'Пропускаем создание индекса : таблица отсутствует', index_sql=index_sql, table_name=table_name
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug('Index creation skipped for %s: %s', table_name, e)
logger.debug('Index creation skipped for', table_name=table_name, e=e)
logger.info('База данных успешно инициализирована')
health = await db_manager.health_check()
logger.info('Database health: %s', health)
logger.info('Database health', health=health)
async def close_db():
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -4,13 +4,13 @@ Ban System API Client.
Client for interacting with the BedolagaBan monitoring system.
"""
import logging
from typing import Any
import aiohttp
import structlog
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class BanSystemAPIError(Exception):
@@ -78,7 +78,7 @@ class BanSystemAPI:
response_text = await response.text()
if response.status >= 400:
logger.error(f'Ban System API error: {response.status} - {response_text}')
logger.error('Ban System API error', status=response.status, response_text=response_text)
raise BanSystemAPIError(
message=f'API error {response.status}: {response_text}',
status_code=response.status,
@@ -93,7 +93,7 @@ class BanSystemAPI:
return {}
except aiohttp.ClientError as e:
logger.error(f'Ban System API connection error: {e}')
logger.error('Ban System API connection error', error=e)
raise BanSystemAPIError(message=f'Connection error: {e!s}', status_code=None, response_data=None)
except TimeoutError:
logger.error('Ban System API request timeout')
+8 -8
View File
@@ -1,14 +1,14 @@
import hashlib
import hmac
import logging
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class CryptoBotService:
@@ -49,11 +49,11 @@ class CryptoBotService:
if response.status == 200 and response_data.get('ok'):
return response_data.get('result')
logger.error(f'CryptoBot API ошибка: {response_data}')
logger.error('CryptoBot API ошибка', response_data=response_data)
return None
except Exception as e:
logger.error(f'Ошибка запроса к CryptoBot API: {e}')
logger.error('Ошибка запроса к CryptoBot API', error=e)
return None
async def get_me(self) -> dict[str, Any] | None:
@@ -81,7 +81,7 @@ class CryptoBotService:
result = await self._make_request('POST', 'createInvoice', data)
if result:
logger.info(f'Создан CryptoBot invoice {result.get("invoice_id")} на {amount} {asset}')
logger.info('Создан CryptoBot invoice на', get=result.get('invoice_id'), amount=amount, asset=asset)
return result
@@ -140,7 +140,7 @@ class CryptoBotService:
return is_valid
except Exception as e:
logger.error(f'Ошибка проверки подписи CryptoBot webhook: {e}')
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
return False
async def process_webhook(self, webhook_data: dict[str, Any]) -> dict[str, Any] | None:
@@ -161,9 +161,9 @@ class CryptoBotService:
'payment_system': 'cryptobot',
}
logger.warning(f'Неизвестный тип CryptoBot webhook: {update_type}')
logger.warning('Неизвестный тип CryptoBot webhook', update_type=update_type)
return None
except Exception as e:
logger.error(f'Ошибка обработки CryptoBot webhook: {e}')
logger.error('Ошибка обработки CryptoBot webhook', error=e)
return None
+13 -9
View File
@@ -6,15 +6,15 @@ import base64
import hashlib
import hmac
import json
import logging
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class HeleketService:
@@ -93,26 +93,28 @@ class HeleketService:
):
text = await response.text()
if response.content_type != 'application/json':
logger.error('Ответ Heleket не JSON (%s): %s', response.content_type, text)
logger.error('Ответ Heleket не JSON', content_type=response.content_type, text=text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error('Ошибка парсинга Heleket JSON: %s', text)
logger.error('Ошибка парсинга Heleket JSON', text=text)
return None
if response.status >= 400:
logger.error('Heleket API %s вернул статус %s: %s', endpoint, response.status, data)
logger.error(
'Heleket API вернул статус', endpoint=endpoint, response_status=response.status, data=data
)
return None
if isinstance(data, dict) and data.get('state') == 0:
return data
logger.error('Heleket API вернул ошибку: %s', data)
logger.error('Heleket API вернул ошибку', data=data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error('Ошибка запроса к Heleket API: %s', error)
logger.error('Ошибка запроса к Heleket API', error=error)
return None
async def create_payment(self, payload: dict[str, Any]) -> dict[str, Any] | None:
@@ -157,7 +159,7 @@ class HeleketService:
return True
if not isinstance(payload, dict):
logger.error('Heleket webhook payload не dict: %s', payload)
logger.error('Heleket webhook payload не dict', payload=payload)
return False
signature = payload.get('sign')
@@ -173,5 +175,7 @@ class HeleketService:
is_valid = hmac.compare_digest(expected, str(signature))
if not is_valid:
logger.error('Неверная подпись Heleket webhook: ожидается %s, получено %s', expected, signature)
logger.error(
'Неверная подпись Heleket webhook: ожидается , получено', expected=expected, signature=signature
)
return is_valid
+10 -10
View File
@@ -1,8 +1,8 @@
import asyncio
import json
import logging
from typing import Any
import structlog
from aiohttp import web
from app.config import settings
@@ -11,7 +11,7 @@ from app.external.heleket import HeleketService
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class HeleketWebhookHandler:
@@ -39,7 +39,7 @@ class HeleketWebhookHandler:
processed = await self.payment_service.process_heleket_webhook(db, payload)
await db.commit()
except Exception as e:
logger.error(f'Ошибка обработки Heleket webhook: {e}')
logger.error('Ошибка обработки Heleket webhook', error=e)
await db.rollback()
return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500)
@@ -97,15 +97,15 @@ async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
try:
await site.start()
logger.info(
'Heleket webhook сервер запущен на %s:%s',
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
'Heleket webhook сервер запущен на',
HELEKET_WEBHOOK_HOST=settings.HELEKET_WEBHOOK_HOST,
HELEKET_WEBHOOK_PORT=settings.HELEKET_WEBHOOK_PORT,
)
logger.info(
'Heleket webhook URL: http://%s:%s%s',
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
settings.HELEKET_WEBHOOK_PATH,
'Heleket webhook URL: http://',
HELEKET_WEBHOOK_HOST=settings.HELEKET_WEBHOOK_HOST,
HELEKET_WEBHOOK_PORT=settings.HELEKET_WEBHOOK_PORT,
HELEKET_WEBHOOK_PATH=settings.HELEKET_WEBHOOK_PATH,
)
while True:
+6 -15
View File
@@ -3,17 +3,17 @@
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class Pal24APIError(Exception):
@@ -96,30 +96,21 @@ class Pal24Client:
payload = await response.json(content_type=None)
except aiohttp.ContentTypeError:
text_body = await response.text()
logger.error(
'Pal24 API returned non-JSON response for %s: %s',
endpoint,
text_body,
)
logger.error('Pal24 API returned non-JSON response for', endpoint=endpoint, text_body=text_body)
raise Pal24APIError(f'Pal24 API returned non-JSON response: {text_body}') from None
result = Pal24Response.from_payload(payload, status)
if status >= 400 or not result.success:
logger.error(
'Pal24 API error %s %s: %s',
status,
endpoint,
payload,
)
logger.error('Pal24 API error', status=status, endpoint=endpoint, payload=payload)
result.raise_for_status(endpoint)
return result
except TimeoutError as error:
logger.error('Pal24 API request timeout for %s: %s', endpoint, error)
logger.error('Pal24 API request timeout for', endpoint=endpoint, error=error)
raise Pal24APIError(f'Pal24 API request timeout for {endpoint}') from error
except aiohttp.ClientError as error:
logger.error('Pal24 API client error for %s: %s', endpoint, error)
logger.error('Pal24 API client error for', endpoint=endpoint, error=error)
raise Pal24APIError(str(error)) from error
# API methods -----------------------------------------------------------------
+28 -28
View File
@@ -1,7 +1,6 @@
import asyncio
import base64
import json
import logging
import ssl
from dataclasses import dataclass
from datetime import datetime
@@ -10,9 +9,10 @@ from typing import Any
from urllib.parse import urlparse
import aiohttp
import structlog
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class UserStatus(Enum):
@@ -310,7 +310,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.debug(f'Подключение к Remnawave: {self.base_url} (тип: {conn_type})')
logger.debug('Подключение к Remnawave: (тип: )', base_url=self.base_url, conn_type=conn_type)
headers = self._prepare_auth_headers()
@@ -319,10 +319,10 @@ class RemnaWaveAPI:
if ':' in self.secret_key:
key_name, key_value = self.secret_key.split(':', 1)
cookies = {key_name: key_value}
logger.debug(f'Используем куки: {key_name}=***')
logger.debug('Используем куки: =***', key_name=key_name)
else:
cookies = {self.secret_key: self.secret_key}
logger.debug(f'Используем куки: {self.secret_key}=***')
logger.debug('Используем куки: =***', secret_key=self.secret_key)
connector_kwargs = {}
@@ -388,12 +388,12 @@ class RemnaWaveAPI:
if response.status == 429 and attempt < max_retries:
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
logger.warning(
'Rate limited (429) on %s %s, retry %d/%d after %.1fs',
method,
endpoint,
attempt + 1,
max_retries,
retry_after,
'Rate limited (429) on , retry / after s',
method=method,
endpoint=endpoint,
attempt=attempt + 1,
max_retries=max_retries,
retry_after=retry_after,
)
await asyncio.sleep(retry_after)
continue
@@ -411,17 +411,17 @@ class RemnaWaveAPI:
if attempt < max_retries:
delay = base_delay * (2**attempt)
logger.warning(
'Request failed on %s %s: %s, retry %d/%d after %.1fs',
method,
endpoint,
e,
attempt + 1,
max_retries,
delay,
'Request failed on retry / after s',
method=method,
endpoint=endpoint,
e=e,
attempt=attempt + 1,
max_retries=max_retries,
delay=delay,
)
await asyncio.sleep(delay)
continue
logger.error(f'Request failed: {e}')
logger.error('Request failed', error=e)
raise RemnaWaveAPIError(f'Request failed: {e!s}')
raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}')
@@ -461,7 +461,7 @@ class RemnaWaveAPI:
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
logger.debug('Создание пользователя в панели: %s', data)
logger.debug('Создание пользователя в панели', data=data)
response = await self._make_request('POST', '/api/users', data)
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
@@ -1049,13 +1049,13 @@ class RemnaWaveAPI:
delete_data = {'userUuid': user_uuid, 'hwid': device_hwid}
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
except Exception as device_error:
logger.error(f'Ошибка удаления устройства {device_hwid}: {device_error}')
logger.error('Ошибка удаления устройства', device_hwid=device_hwid, device_error=device_error)
failed_count += 1
return failed_count < len(devices) / 2
except Exception as e:
logger.error(f'Ошибка при сбросе устройств: {e}')
logger.error('Ошибка при сбросе устройств', error=e)
return False
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
@@ -1064,7 +1064,7 @@ class RemnaWaveAPI:
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
return True
except Exception as e:
logger.error(f'Ошибка удаления устройства {device_hwid}: {e}')
logger.error('Ошибка удаления устройства', device_hwid=device_hwid, error=e)
return False
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> str | None:
@@ -1073,10 +1073,10 @@ class RemnaWaveAPI:
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
return response.get('response', {}).get('encryptedLink')
except RemnaWaveAPIError as e:
logger.warning(f'Не удалось зашифровать happ ссылку: {e.message}')
logger.warning('Не удалось зашифровать happ ссылку', message=e.message)
return None
except Exception as e:
logger.warning(f'Ошибка при шифровании happ ссылки: {e}')
logger.warning('Ошибка при шифровании happ ссылки', error=e)
return None
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
@@ -1112,7 +1112,7 @@ class RemnaWaveAPI:
try:
status = UserStatus(status_str)
except ValueError:
logger.warning(f'Неизвестный статус пользователя: {status_str}, используем ACTIVE')
logger.warning('Неизвестный статус пользователя: используем ACTIVE', status_str=status_str)
status = UserStatus.ACTIVE
# Получаем trafficLimitStrategy с fallback
@@ -1120,7 +1120,7 @@ class RemnaWaveAPI:
try:
traffic_strategy = TrafficLimitStrategy(strategy_str)
except ValueError:
logger.warning(f'Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET')
logger.warning('Неизвестная стратегия трафика: используем NO_RESET', strategy_str=strategy_str)
traffic_strategy = TrafficLimitStrategy.NO_RESET
return RemnaWaveUser(
@@ -1285,5 +1285,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
await api.get_system_stats()
return True
except Exception as e:
logger.warning('API connection test failed: %s', e)
logger.warning('API connection test failed', e=e)
return False
+16 -10
View File
@@ -1,14 +1,14 @@
import logging
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
import structlog
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, LabeledPrice
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class TelegramStarsService:
@@ -49,13 +49,16 @@ class TelegramStarsService:
)
logger.info(
f'Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) '
f'для {chat_id}, курс: {stars_rate}₽/⭐'
'Создан Stars invoice на звезд (~) для курс: ₽/⭐',
stars_amount=stars_amount,
settings=settings.format_price(amount_kopeks),
chat_id=chat_id,
stars_rate=stars_rate,
)
return invoice_link
except Exception as e:
logger.error(f'Ошибка создания Stars invoice: {e}')
logger.error('Ошибка создания Stars invoice', error=e)
return None
async def send_invoice(
@@ -84,8 +87,11 @@ class TelegramStarsService:
)
logger.info(
f'Отправлен Stars invoice {message.message_id} на {stars_amount} звезд '
f'(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐'
'Отправлен Stars invoice на звезд (~), курс: ₽/⭐',
message_id=message.message_id,
stars_amount=stars_amount,
settings=settings.format_price(amount_kopeks),
stars_rate=stars_rate,
)
return {
'message_id': message.message_id,
@@ -95,7 +101,7 @@ class TelegramStarsService:
}
except Exception as e:
logger.error(f'Ошибка отправки Stars invoice: {e}')
logger.error('Ошибка отправки Stars invoice', error=e)
return None
async def answer_pre_checkout_query(
@@ -105,8 +111,8 @@ class TelegramStarsService:
await self.bot.answer_pre_checkout_query(
pre_checkout_query_id=pre_checkout_query_id, ok=ok, error_message=error_message
)
logger.info(f'Ответ на pre_checkout_query: ok={ok}')
logger.info('Ответ на pre_checkout_query: ok', ok=ok)
return True
except Exception as e:
logger.error(f'Ошибка ответа на pre_checkout_query: {e}')
logger.error('Ошибка ответа на pre_checkout_query', error=e)
return False
+24 -17
View File
@@ -1,13 +1,14 @@
import hashlib
import hmac
import json
import logging
from typing import Any
import structlog
from app.config import settings
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class TributeService:
@@ -25,11 +26,11 @@ class TributeService:
try:
payment_url = f'{self.donate_link}&user_id={user_id}'
logger.info(f'Создана ссылка Tribute для пользователя {user_id}')
logger.info('Создана ссылка Tribute для пользователя', user_id=user_id)
return payment_url
except Exception as e:
logger.error(f'Ошибка создания Tribute ссылки: {e}')
logger.error('Ошибка создания Tribute ссылки', error=e)
return None
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
@@ -50,7 +51,7 @@ class TributeService:
return is_valid
except Exception as e:
logger.error(f'Ошибка проверки подписи webhook: {e}')
logger.error('Ошибка проверки подписи webhook', error=e)
return False
async def process_webhook(self, payload_or_data) -> dict[str, Any] | None:
@@ -60,9 +61,9 @@ class TributeService:
if isinstance(payload_or_data, str):
try:
webhook_data = json.loads(payload_or_data)
logger.info(f'📊 Распарсенные данные: {webhook_data}')
logger.info('📊 Распарсенные данные', webhook_data=webhook_data)
except json.JSONDecodeError as e:
logger.error(f'❌ Ошибка парсинга JSON: {e}')
logger.error('❌ Ошибка парсинга JSON', error=e)
return None
else:
webhook_data = payload_or_data
@@ -99,18 +100,24 @@ class TributeService:
status = 'unknown'
logger.info(
f'📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}'
'📝 Извлеченные данные: payment_id=, status=, amount_kopeks=, user_id',
payment_id=payment_id,
status=status,
amount_kopeks=amount_kopeks,
telegram_user_id=telegram_user_id,
)
if not telegram_user_id:
logger.error('❌ Не найден telegram_user_id в webhook данных')
logger.error(f'🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}')
logger.error(
'🔍 Полные данные для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2)
)
return None
try:
telegram_user_id = int(telegram_user_id)
except (ValueError, TypeError):
logger.error(f'❌ Некорректный telegram_user_id: {telegram_user_id}')
logger.error('❌ Некорректный telegram_user_id', telegram_user_id=telegram_user_id)
return None
result = {
@@ -123,28 +130,28 @@ class TributeService:
'payment_system': 'tribute',
}
logger.info(f'✅ Tribute webhook обработан успешно: {result}')
logger.info('✅ Tribute webhook обработан успешно', result=result)
return result
except Exception as e:
logger.error(f'❌ Ошибка обработки Tribute webhook: {e}', exc_info=True)
logger.error(f'🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}')
logger.error('❌ Ошибка обработки Tribute webhook', error=e, exc_info=True)
logger.error('🔍 Webhook data для отладки', dumps=json.dumps(webhook_data, ensure_ascii=False, indent=2))
return None
async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None:
try:
logger.info(f'Запрос статуса платежа {payment_id}')
logger.info('Запрос статуса платежа', payment_id=payment_id)
return {'status': 'unknown', 'payment_id': payment_id}
except Exception as e:
logger.error(f'Ошибка получения статуса платежа: {e}')
logger.error('Ошибка получения статуса платежа', error=e)
return None
async def refund_payment(
self, payment_id: str, amount_kopeks: int | None = None, reason: str = 'Возврат по запросу'
) -> dict[str, Any] | None:
try:
logger.info(f'Создание возврата для платежа {payment_id}')
logger.info('Создание возврата для платежа', payment_id=payment_id)
return {'refund_id': f'refund_{payment_id}', 'status': 'pending'}
except Exception as e:
logger.error(f'Ошибка создания возврата: {e}')
logger.error('Ошибка создания возврата', error=e)
return None
+21 -28
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import asyncio
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Any
import aiohttp
import structlog
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
@@ -18,7 +18,7 @@ from app.database.database import AsyncSessionLocal
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class WataPublicKeyProvider:
@@ -67,28 +67,24 @@ class WataPublicKeyProvider:
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
text = await response.text()
if response.status >= 400:
logger.error(
'Ошибка получения публичного ключа WATA %s: %s',
response.status,
text,
)
logger.error('Ошибка получения публичного ключа WATA', response_status=response.status, text=text)
return None
try:
payload = await response.json()
except aiohttp.ContentTypeError:
logger.error('Ответ WATA public-key не является JSON: %s', text)
logger.error('Ответ WATA public-key не является JSON', text=text)
return None
if isinstance(payload, dict):
value = payload.get('value')
if value:
return value
logger.error('Ответ WATA public-key не содержит ключ: %s', payload)
logger.error('Ответ WATA public-key не содержит ключ', payload=payload)
else:
logger.error('Неожиданный формат ответа WATA public-key: %s', payload)
logger.error('Неожиданный формат ответа WATA public-key', payload=payload)
except Exception as error:
logger.error('Ошибка запроса публичного ключа WATA: %s', error)
logger.error('Ошибка запроса публичного ключа WATA', error=error)
return None
@@ -125,7 +121,7 @@ class WataWebhookHandler:
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode('utf-8'))
except ValueError as error:
logger.error('Ошибка загрузки публичного ключа WATA: %s', error)
logger.error('Ошибка загрузки публичного ключа WATA', error=error)
return False
try:
@@ -140,7 +136,7 @@ class WataWebhookHandler:
logger.warning('Подпись WATA webhook не прошла проверку')
return False
except Exception as error:
logger.error('Ошибка проверки подписи WATA: %s', error)
logger.error('Ошибка проверки подписи WATA', error=error)
return False
async def handle_webhook(self, request: web.Request) -> web.Response:
@@ -164,9 +160,9 @@ class WataWebhookHandler:
return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400)
logger.info(
'Получен WATA webhook: order_id=%s, status=%s',
payload.get('orderId'),
payload.get('transactionStatus'),
'Получен WATA webhook: order_id status',
payload=payload.get('orderId'),
payload_2=payload.get('transactionStatus'),
)
processed: bool | None = None
@@ -175,7 +171,7 @@ class WataWebhookHandler:
processed = await self.payment_service.process_wata_webhook(db, payload)
await db.commit()
except Exception as e:
logger.error(f'Ошибка обработки WATA webhook: {e}')
logger.error('Ошибка обработки WATA webhook', error=e)
await db.rollback()
return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500)
@@ -218,10 +214,7 @@ def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
app.router.add_get('/health', handler.health_check)
logger.info(
'Настроен WATA webhook endpoint на %s',
settings.WATA_WEBHOOK_PATH,
)
logger.info('Настроен WATA webhook endpoint на', WATA_WEBHOOK_PATH=settings.WATA_WEBHOOK_PATH)
return app
@@ -244,15 +237,15 @@ async def start_wata_webhook_server(payment_service: PaymentService) -> None:
try:
await site.start()
logger.info(
'WATA webhook сервер запущен на %s:%s',
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
'WATA webhook сервер запущен на',
WATA_WEBHOOK_HOST=settings.WATA_WEBHOOK_HOST,
WATA_WEBHOOK_PORT=settings.WATA_WEBHOOK_PORT,
)
logger.info(
'WATA webhook URL: http://%s:%s%s',
settings.WATA_WEBHOOK_HOST,
settings.WATA_WEBHOOK_PORT,
settings.WATA_WEBHOOK_PATH,
'WATA webhook URL: http://',
WATA_WEBHOOK_HOST=settings.WATA_WEBHOOK_HOST,
WATA_WEBHOOK_PORT=settings.WATA_WEBHOOK_PORT,
WATA_WEBHOOK_PATH=settings.WATA_WEBHOOK_PATH,
)
while True:

Some files were not shown because too many files have changed in this diff Show More