Compare commits

..

1 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
249 changed files with 10608 additions and 5016 deletions
+1 -6
View File
@@ -371,8 +371,7 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Показывать раздел партнёрки в кабинете
REFERRAL_PARTNER_SECTION_VISIBLE=true
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
@@ -385,8 +384,6 @@ REFERRAL_WITHDRAWAL_ENABLED=false
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
@@ -848,8 +845,6 @@ VERSION_CHECK_INTERVAL_HOURS=1
# ===== ЛОГИРОВАНИЕ =====
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
LOG_COLORS=true
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
+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.16.0"
".": "3.12.1"
}
-118
View File
@@ -1,123 +1,5 @@
# Changelog
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
### New Features
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
### Bug Fixes
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
### Refactoring
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
### New Features
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
### Bug Fixes
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
### Bug Fixes
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
### New Features
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
### Bug Fixes
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
### Refactoring
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
### New Features
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
### Bug Fixes
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
### Refactoring
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.16.0" # x-release-please-version
ARG VERSION="v3.12.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
-16
View File
@@ -40,22 +40,6 @@ fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
.PHONY: migrate
migrate: ## Применить миграции (alembic upgrade head)
uv run alembic upgrade head
.PHONY: migration
migration: ## Создать миграцию (usage: make migration m="description")
uv run alembic revision --autogenerate -m "$(m)"
.PHONY: migrate-stamp
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
uv run alembic stamp head
.PHONY: migrate-history
migrate-history: ## Показать историю миграций
uv run alembic history --verbose
.PHONY: help
help: ## Показать список доступных команд
@echo ""
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = migrations/alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
[post_write_hooks]
+5 -5
View File
@@ -1,7 +1,7 @@
"""Email verification token generation and validation."""
import secrets
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from app.config import settings
@@ -24,7 +24,7 @@ def get_email_change_expires_at() -> datetime:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.now(UTC) + timedelta(minutes=minutes)
return datetime.utcnow() + timedelta(minutes=minutes)
def generate_verification_token() -> str:
@@ -55,7 +55,7 @@ def get_verification_expires_at() -> datetime:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.now(UTC) + timedelta(hours=hours)
return datetime.utcnow() + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
@@ -66,7 +66,7 @@ def get_password_reset_expires_at() -> datetime:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.now(UTC) + timedelta(hours=hours)
return datetime.utcnow() + timedelta(hours=hours)
def is_token_expired(expires_at: datetime | None) -> bool:
@@ -81,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
"""
if expires_at is None:
return True
return datetime.now(UTC) > expires_at
return datetime.utcnow() > expires_at
+6 -6
View File
@@ -1,6 +1,6 @@
"""JWT token handling for cabinet authentication."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any
import jwt
@@ -23,13 +23,13 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
Encoded JWT access token
"""
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
payload = {
'sub': str(user_id),
'type': 'access',
'exp': expires,
'iat': datetime.now(UTC),
'iat': datetime.utcnow(),
}
# Добавляем telegram_id только если он есть
@@ -51,13 +51,13 @@ def create_refresh_token(user_id: int) -> str:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.now(UTC) + timedelta(days=expire_days)
expires = datetime.utcnow() + timedelta(days=expire_days)
payload = {
'sub': str(user_id),
'type': 'refresh',
'exp': expires,
'iat': datetime.now(UTC),
'iat': datetime.utcnow(),
}
secret = settings.get_cabinet_jwt_secret()
@@ -108,4 +108,4 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.now(UTC) + timedelta(days=expire_days)
return datetime.utcnow() + timedelta(days=expire_days)
+5 -5
View File
@@ -3,7 +3,7 @@
import hashlib
import hmac
import json
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, unquote
@@ -34,8 +34,8 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
@@ -81,8 +81,8 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
age = (datetime.now(UTC) - auth_time).total_seconds()
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
-8
View File
@@ -8,7 +8,6 @@ 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_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
@@ -24,7 +23,6 @@ from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .admin_withdrawals import router as admin_withdrawals_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
@@ -33,7 +31,6 @@ from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
@@ -46,7 +43,6 @@ from .ticket_notifications import (
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Main cabinet router
@@ -58,8 +54,6 @@ router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
@@ -89,8 +83,6 @@ router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)
+3 -5
View File
@@ -1,6 +1,6 @@
"""Admin routes for broadcasts in cabinet."""
from datetime import UTC, datetime
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -118,10 +118,9 @@ EMAIL_FILTER_GROUPS = {
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
blocked = broadcast.blocked_count or 0
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
@@ -134,7 +133,6 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
blocked_count=blocked,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
@@ -726,7 +724,7 @@ async def stop_broadcast(
broadcast.status = 'cancelling'
else:
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.now(UTC)
broadcast.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(broadcast)
+12 -90
View File
@@ -1,7 +1,5 @@
"""Admin routes for managing advertising campaigns in cabinet."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
@@ -22,9 +20,7 @@ from app.database.crud.campaign import (
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
PartnerStatus,
Subscription,
Tariff,
User,
@@ -32,7 +28,6 @@ from app.database.models import (
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.campaigns import (
AvailablePartnerItem,
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
@@ -62,22 +57,6 @@ def _get_deep_link(start_parameter: str) -> str:
return f'?start={start_parameter}'
def _get_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
"""Get partner display name from campaign."""
if not campaign.partner_user_id or not campaign.partner:
return None
partner = campaign.partner
return partner.first_name or partner.username or f'#{partner.id}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
@@ -153,26 +132,6 @@ async def get_available_tariffs(
]
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
async def get_available_partners(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of approved partners for campaign partner selector."""
result = await db.execute(
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
)
partners = result.scalars().all()
return [
AvailablePartnerItem(
user_id=p.id,
username=p.username,
first_name=p.first_name,
)
for p in partners
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
@@ -199,8 +158,6 @@ async def list_campaigns(
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_at=campaign.created_at,
)
)
@@ -244,13 +201,10 @@ async def get_campaign(
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
partner_user_id=campaign.partner_user_id,
partner_name=_get_partner_name(campaign),
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@@ -294,7 +248,6 @@ async def get_campaign_stats(
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
web_link=_get_web_link(campaign.start_parameter),
)
@@ -335,22 +288,19 @@ async def get_campaign_registrations(
)
total = count_result.scalar() or 0
# Batch query: find which users have active subscriptions (avoids N+1)
user_ids = [user.id for _reg, user in rows]
active_sub_user_ids: set[int] = set()
if user_ids:
sub_result = await db.execute(
select(Subscription.user_id)
.where(
Subscription.user_id.in_(user_ids),
Subscription.status == 'active',
)
.distinct()
)
active_sub_user_ids = set(sub_result.scalars().all())
items = []
for reg, user in rows:
# Check if user has subscription
sub_result = await db.execute(
select(Subscription)
.where(
Subscription.user_id == user.id,
Subscription.status == 'active',
)
.limit(1)
)
has_sub = sub_result.scalar_one_or_none() is not None
items.append(
CampaignRegistrationItem(
id=reg.id,
@@ -365,7 +315,7 @@ async def get_campaign_registrations(
tariff_duration_days=reg.tariff_duration_days,
created_at=reg.created_at,
user_balance_kopeks=user.balance_kopeks or 0,
has_subscription=user.id in active_sub_user_ids,
has_subscription=has_sub,
has_paid=user.has_had_paid_subscription or False,
)
)
@@ -408,15 +358,6 @@ async def create_new_campaign(
detail='Tariff not found',
)
# Validate partner exists and is approved
if request.partner_user_id is not None:
partner_user = await db.get(User, request.partner_user_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign = await create_campaign(
db,
name=request.name,
@@ -431,7 +372,6 @@ async def create_new_campaign(
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
partner_user_id=request.partner_user_id,
)
# Reload to get tariff relationship
@@ -503,26 +443,8 @@ async def update_existing_campaign(
if request.tariff_duration_days is not None:
updates['tariff_duration_days'] = request.tariff_duration_days
# Handle partner_user_id separately (allows explicit None to unassign)
partner_changed = False
if 'partner_user_id' in request.model_fields_set:
new_partner_id = request.partner_user_id
if new_partner_id is not None:
partner_user = await db.get(User, new_partner_id)
if not partner_user or partner_user.partner_status != 'approved':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Partner not found or not approved',
)
campaign.partner_user_id = new_partner_id
campaign.updated_at = datetime.now(UTC)
partner_changed = True
if updates:
await update_campaign(db, campaign, **updates)
elif partner_changed:
await db.commit()
await db.refresh(campaign)
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
-582
View File
@@ -1,582 +0,0 @@
"""Admin routes for managing partners in cabinet."""
from datetime import UTC, datetime
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Settings ====================
class PartnerSettingsResponse(BaseModel):
withdrawal_enabled: bool
withdrawal_min_amount_kopeks: int
withdrawal_cooldown_days: int
withdrawal_requisites_text: str
partner_section_visible: bool
referral_program_enabled: bool
class PartnerSettingsUpdateRequest(BaseModel):
withdrawal_enabled: bool | None = None
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
partner_section_visible: bool | None = None
referral_program_enabled: bool | None = None
def _build_partner_settings_response() -> PartnerSettingsResponse:
return PartnerSettingsResponse(
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
)
@router.get('/settings', response_model=PartnerSettingsResponse)
async def get_partner_settings(
admin: User = Depends(get_current_admin_user),
):
"""Get partner system settings."""
return _build_partner_settings_response()
@router.patch('/settings', response_model=PartnerSettingsResponse)
async def update_partner_settings(
request: PartnerSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update partner system settings."""
from pathlib import Path
# Update in-memory settings
if request.withdrawal_enabled is not None:
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
if request.withdrawal_min_amount_kopeks is not None:
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
if request.withdrawal_cooldown_days is not None:
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
if request.withdrawal_requisites_text is not None:
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
if request.partner_section_visible is not None:
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
if request.referral_program_enabled is not None:
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
# Persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates: dict[str, str] = {}
if request.withdrawal_enabled is not None:
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
if request.withdrawal_min_amount_kopeks is not None:
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
if request.withdrawal_cooldown_days is not None:
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
if request.withdrawal_requisites_text is not None:
# Sanitize: replace newlines to prevent .env injection
sanitized = (
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
)
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
if request.partner_section_visible is not None:
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
if request.referral_program_enabled is not None:
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
new_lines = []
updated_keys: set[str] = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated partner settings in .env file', admin_id=admin.id)
except Exception as e:
logger.warning('Failed to update .env file', error=e)
return _build_partner_settings_response()
# ==================== Applications (static paths first) ====================
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
async def list_applications(
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = (
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
)
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_approved(
user=user,
commission_percent=request.commission_percent,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner approval notification', error=e)
return {'success': True}
@router.post('/applications/{application_id}/reject')
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
application = await db.get(PartnerApplication, application_id)
user = await db.get(User, application.user_id) if application else None
if user:
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_partner_rejected(
user=user,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send partner rejection notification', error=e)
return {'success': True}
# ==================== Stats (static paths) ====================
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
# Atomic check-and-set to prevent race conditions
result = await db.execute(
update(AdvertisingCampaign)
.where(
AdvertisingCampaign.id == campaign_id,
or_(
AdvertisingCampaign.partner_user_id.is_(None),
AdvertisingCampaign.partner_user_id == user_id,
),
)
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания уже привязана к другому партнёру',
)
await db.commit()
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
if campaign.partner_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
campaign.partner_user_id = None
campaign.updated_at = datetime.now(UTC)
await db.commit()
return {'success': True}
+5 -5
View File
@@ -1,7 +1,7 @@
"""Admin routes for pinned messages in cabinet."""
import time
from datetime import UTC, datetime
from datetime import datetime
import structlog
from aiogram import Bot
@@ -227,7 +227,7 @@ async def update_pinned_message(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.now(UTC)
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
@@ -255,7 +255,7 @@ async def update_pinned_message_settings(
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.now(UTC)
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
@@ -332,11 +332,11 @@ async def activate_pinned_message(
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.now(UTC))
.values(is_active=False, updated_at=datetime.utcnow())
)
msg.is_active = True
msg.updated_at = datetime.now(UTC)
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
+2 -2
View File
@@ -162,9 +162,9 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(UTC)
return value.astimezone(UTC).replace(tzinfo=None)
if value.tzinfo is not None:
return value
return value.replace(tzinfo=None)
return value
+3 -6
View File
@@ -1,6 +1,6 @@
"""Admin routes for RemnaWave management in cabinet."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any
import structlog
@@ -109,10 +109,7 @@ def _parse_datetime(value: Any) -> datetime | None:
return value
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed
return datetime.fromisoformat(value)
except ValueError:
return None
return None
@@ -341,7 +338,7 @@ async def get_node_usage(
service = _get_service()
_ensure_configured(service)
end_dt = end or datetime.now(UTC)
end_dt = end or datetime.utcnow()
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
+5 -5
View File
@@ -2,7 +2,7 @@
import sys
import time
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -258,7 +258,7 @@ async def get_dashboard_stats(
sub_stats = await get_subscriptions_statistics(db)
# Get financial statistics
now = datetime.now(UTC)
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
trans_stats = await get_transactions_statistics(db, month_start, now)
@@ -503,7 +503,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
return None
now = datetime.now(UTC)
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -601,7 +601,7 @@ async def get_top_referrers(
):
"""Get top referrers with earnings breakdown by period."""
try:
now = datetime.now(UTC)
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -874,7 +874,7 @@ async def get_recent_payments(
):
"""Get recent payments with user info."""
try:
now = datetime.now(UTC)
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
+1 -1
View File
@@ -535,7 +535,7 @@ async def get_tariff_stats(
# Calculate revenue from subscription payments for users on this tariff
revenue_result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.join(Subscription, Transaction.user_id == Subscription.user_id)
.where(
Subscription.tariff_id == tariff_id,
+7 -7
View File
@@ -1,7 +1,7 @@
"""Admin tickets routes for cabinet."""
import math
from datetime import UTC, datetime
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -269,7 +269,7 @@ async def update_ticket_settings(
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
@@ -447,13 +447,13 @@ async def reply_to_ticket(
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.now(UTC),
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status to answered
ticket.status = 'answered'
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
@@ -522,9 +522,9 @@ async def update_ticket_status(
)
ticket.status = request.status
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
if request.status == 'closed':
ticket.closed_at = datetime.now(UTC)
ticket.closed_at = datetime.utcnow()
else:
ticket.closed_at = None
@@ -581,7 +581,7 @@ async def update_ticket_priority(
)
ticket.priority = request.priority
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(ticket)
+1 -1
View File
@@ -383,7 +383,7 @@ async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int,
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
+3 -3
View File
@@ -1,6 +1,6 @@
"""Admin routes for version and release information."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import aiohttp
import structlog
@@ -53,7 +53,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
@@ -75,7 +75,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now(UTC)
_cabinet_last_check = datetime.now()
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
return releases
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
+52 -43
View File
@@ -1,6 +1,6 @@
"""Admin routes for managing users in cabinet."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -35,7 +35,7 @@ from app.database.models import (
User,
UserStatus,
)
from app.utils.timezone import panel_datetime_to_utc
from app.utils.timezone import panel_datetime_to_naive_utc
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
@@ -144,9 +144,9 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
is_active = False
if subscription.end_date:
delta = subscription.end_date - datetime.now(UTC)
delta = subscription.end_date - datetime.utcnow()
days_remaining = max(0, delta.days)
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.now(UTC)
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.utcnow()
return UserSubscriptionInfo(
id=subscription.id,
@@ -174,7 +174,7 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
tariff_name = tariff.name
# Fetch traffic purchases
now = datetime.now(UTC)
now = datetime.utcnow()
tp_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
@@ -225,13 +225,13 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
is_active = (
subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and subscription.end_date
and subscription.end_date > datetime.now(UTC)
and subscription.end_date > datetime.utcnow()
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
expire_at = subscription.end_date
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -310,7 +310,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
# Create new user
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -330,7 +330,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
changes['panel_uuid'] = 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.now(UTC)
user.last_remnawave_sync = datetime.utcnow()
await db.commit()
return changes
@@ -421,7 +421,7 @@ async def get_users_stats(
func.cast(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > datetime.now(UTC),
Subscription.end_date > datetime.utcnow(),
),
Integer,
)
@@ -431,7 +431,7 @@ async def get_users_stats(
func.cast(
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= datetime.now(UTC),
Subscription.end_date <= datetime.utcnow(),
),
Integer,
)
@@ -456,7 +456,7 @@ async def get_users_stats(
avg_balance = int(balance_row.avg or 0) if balance_row else 0
# Get activity stats
now = datetime.now(UTC)
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -756,7 +756,7 @@ async def get_user_node_usage(
if not service.is_configured:
return UserNodeUsageResponse(items=[])
end_date = datetime.now(UTC)
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=30)
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
@@ -1011,7 +1011,7 @@ async def update_user_subscription(
)
subscription.end_date = request.end_date
if request.end_date > datetime.now(UTC):
if request.end_date > datetime.utcnow():
subscription.status = SubscriptionStatus.ACTIVE.value
else:
subscription.status = SubscriptionStatus.EXPIRED.value
@@ -1107,7 +1107,7 @@ async def update_user_subscription(
if request.action == 'cancel':
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.end_date = datetime.now(UTC)
subscription.end_date = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -1124,9 +1124,9 @@ async def update_user_subscription(
if request.action == 'activate':
subscription.status = SubscriptionStatus.ACTIVE.value
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
if subscription.end_date and subscription.end_date <= datetime.utcnow():
# Extend by 30 days if expired
subscription.end_date = datetime.now(UTC) + timedelta(days=30)
subscription.end_date = datetime.utcnow() + timedelta(days=30)
await db.commit()
await db.refresh(subscription)
@@ -1196,7 +1196,7 @@ async def update_user_subscription(
await db.delete(traffic_purchase)
# Recalculate traffic_reset_at from remaining active purchases
now = datetime.now(UTC)
now = datetime.utcnow()
remaining_query = select(TrafficPurchase).where(
TrafficPurchase.subscription_id == subscription.id,
TrafficPurchase.expires_at > now,
@@ -1388,7 +1388,7 @@ async def update_user_status(
)
user.status = new_status
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -1456,7 +1456,7 @@ async def update_user_restrictions(
if request.restriction_reason is not None:
user.restriction_reason = request.restriction_reason
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -1511,7 +1511,7 @@ async def update_user_promo_group(
promo_group_name = promo_group.name
user.promo_group_id = new_promo_group_id
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -1552,7 +1552,7 @@ async def update_user_referral_commission(
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(
@@ -1837,7 +1837,7 @@ async def reset_user_trial(
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
@@ -1907,7 +1907,7 @@ async def reset_user_subscription(
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
@@ -1971,7 +1971,7 @@ async def disable_user(
# Block user account
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
@@ -2179,8 +2179,13 @@ async def get_user_sync_status(
differences.append(f'Status: bot={bot_sub_status}, panel={panel_status}')
if bot_sub_end_date and panel_expire_at:
bot_end_utc = bot_sub_end_date if bot_sub_end_date.tzinfo else bot_sub_end_date
panel_end_utc = panel_datetime_to_utc(panel_expire_at)
# Convert both to naive UTC for comparison
# Bot dates are stored as naive UTC
bot_end_utc = (
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
)
# Panel returns local time with misleading +00:00 offset
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
# Allow for timezone offset (3 hours = MSK) and small sync delays
@@ -2314,7 +2319,7 @@ async def sync_user_from_panel(
short_uuid=panel_user.short_uuid,
username=panel_user.username,
status=panel_user.status.value if panel_user.status else None,
expire_at=panel_datetime_to_utc(panel_user.expire_at) if panel_user.expire_at else None,
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
device_limit=panel_user.hwid_device_limit or 1,
@@ -2333,10 +2338,13 @@ async def sync_user_from_panel(
# Update end date (normalize timezone)
if panel_user.expire_at:
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
# Panel returns local time with misleading +00:00 offset
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
if sub_end_utc != panel_expire_utc:
sub_end_naive = (
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
)
if sub_end_naive != panel_expire_utc:
changes['end_date'] = {
'old': sub.end_date.isoformat() if sub.end_date else None,
'new': panel_expire_utc.isoformat(),
@@ -2345,7 +2353,7 @@ async def sync_user_from_panel(
# Update status
panel_status_str = panel_user.status.value if panel_user.status else 'DISABLED'
now = datetime.now(UTC)
now = datetime.utcnow()
# Compare with normalized panel expire date
panel_expire_for_check = panel_expire_utc if panel_user.expire_at else None
if panel_status_str == 'ACTIVE' and panel_expire_for_check and panel_expire_for_check > now:
@@ -2402,8 +2410,9 @@ async def sync_user_from_panel(
panel_traffic_limit = (
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
)
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
# Panel returns local time with misleading +00:00 offset
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
new_sub = await create_paid_subscription(
db=db,
@@ -2418,8 +2427,8 @@ async def sync_user_from_panel(
changes['subscription_created'] = True
# Update last sync time
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
await db.commit()
@@ -2493,14 +2502,14 @@ async def sync_user_to_panel(
is_active = (
sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value)
and sub.end_date
and sub.end_date > datetime.now(UTC)
and sub.end_date > datetime.utcnow()
)
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
# Ensure expire_at is in future for panel
expire_at = sub.end_date
if expire_at and expire_at <= datetime.now(UTC):
expire_at = datetime.now(UTC) + timedelta(minutes=1)
if expire_at and expire_at <= datetime.utcnow():
expire_at = datetime.utcnow() + timedelta(minutes=1)
username = settings.format_remnawave_username(
full_name=user.full_name,
@@ -2586,7 +2595,7 @@ async def sync_user_to_panel(
# Create new user in panel
create_kwargs = {
'username': username,
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
'status': panel_status,
'traffic_limit_bytes': traffic_limit_bytes,
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
@@ -2611,8 +2620,8 @@ async def sync_user_to_panel(
action = 'created'
# Update last sync time
user.last_remnawave_sync = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.last_remnawave_sync = datetime.utcnow()
user.updated_at = datetime.utcnow()
await db.commit()
-302
View File
@@ -1,302 +0,0 @@
"""Admin routes for managing withdrawal requests in cabinet."""
import json
from typing import Literal
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
None, alias='status'
),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Notify user about approval
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\n{request.comment}' if request.comment else ''
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_approved(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal approval notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/reject')
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success, error = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось отклонить заявку',
)
# Notify user about rejection
try:
from aiogram import Bot
from app.config import settings
from app.services.notification_delivery_service import notification_delivery_service
if settings.BOT_TOKEN:
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
user = await db.get(User, withdrawal.user_id) if withdrawal else None
if user and withdrawal:
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
bot = Bot(token=settings.BOT_TOKEN)
try:
await notification_delivery_service.notify_withdrawal_rejected(
user=user,
amount_kopeks=withdrawal.amount_kopeks,
comment=request.comment,
bot=bot,
telegram_message=tg_message,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send withdrawal rejection notification', error=e)
return {'success': True}
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success, error = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error or 'Не удалось завершить заявку',
)
return {'success': True}
+20 -102
View File
@@ -11,10 +11,6 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_registration_by_user,
)
from app.database.crud.user import (
clear_email_change_pending,
create_user,
@@ -27,10 +23,9 @@ from app.database.crud.user import (
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.services.campaign_service import AdvertisingCampaignService
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_utc
from app.utils.timezone import panel_datetime_to_naive_utc
from ..auth import (
create_access_token,
@@ -54,7 +49,6 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.auth import (
AuthResponse,
CampaignBonusInfo,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
@@ -124,6 +118,12 @@ async def _store_refresh_token(
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
# Check if token already exists (handles race conditions)
existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash))
if existing.scalar_one_or_none():
# Token already stored, skip
return
token_record = CabinetRefreshToken(
user_id=user_id,
token_hash=token_hash,
@@ -133,71 +133,9 @@ async def _store_refresh_token(
db.add(token_record)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
async def _process_campaign_bonus(
db: AsyncSession,
user: User,
campaign_slug: str | None,
) -> CampaignBonusInfo | None:
"""Process campaign bonus for user during auth. Never raises."""
if not campaign_slug:
return None
try:
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
if not campaign:
return None
# Lock user row to prevent concurrent bonus application (race condition)
await db.execute(select(User).where(User.id == user.id).with_for_update())
existing = await get_campaign_registration_by_user(db, user.id)
if existing:
logger.debug('User already has campaign registration', user_id=user.id)
return None
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
if campaign.partner_user_id and not user.referred_by_id:
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
partner_user_id=campaign.partner_user_id,
campaign_id=campaign.id,
)
except Exception as e:
logger.error('Failed to process referral from campaign partner', error=e)
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Refresh user to get updated balance after bonus
await db.refresh(user)
return CampaignBonusInfo(
campaign_name=campaign.name,
bonus_type=result.bonus_type or campaign.bonus_type,
balance_kopeks=result.balance_kopeks,
subscription_days=result.subscription_days,
tariff_name=result.tariff_name,
)
except Exception:
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
try:
await db.rollback()
# Re-fetch user so session stays usable for the caller
await db.refresh(user)
except Exception:
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
return None
# Handle race condition if token was inserted between check and insert
await db.rollback()
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
@@ -237,7 +175,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_utc(panel_user.expire_at)
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -248,7 +186,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
device_limit = panel_user.hwid_device_limit or 1
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC)
current_time = datetime.now(UTC).replace(tzinfo=None)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -375,7 +313,7 @@ async def auth_telegram(
)
# Update last login
user.cabinet_last_login = datetime.now(UTC)
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
@@ -383,11 +321,6 @@ async def auth_telegram(
# Store refresh token
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -402,7 +335,7 @@ async def auth_telegram_widget(
This endpoint validates data from Telegram Login Widget and returns
JWT tokens for authenticated access.
"""
widget_data = request.model_dump(exclude={'campaign_slug'})
widget_data = request.model_dump()
if not validate_telegram_login_widget(widget_data):
raise HTTPException(
@@ -441,17 +374,12 @@ async def auth_telegram_widget(
if request.last_name != user.last_name:
user.last_name = request.last_name
user.cabinet_last_login = datetime.now(UTC)
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -618,7 +546,7 @@ async def register_email_standalone(
# Для тестового email - автоматически верифицировать
if is_test_email:
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
user.email_verified_at = datetime.utcnow()
await db.commit()
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
else:
@@ -705,10 +633,10 @@ async def verify_email(
# Mark email as verified
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
user.email_verified_at = datetime.utcnow()
user.email_verification_token = None
user.email_verification_expires = None
user.cabinet_last_login = datetime.now(UTC)
user.cabinet_last_login = datetime.utcnow()
await db.commit()
@@ -719,11 +647,6 @@ async def verify_email(
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -827,7 +750,7 @@ async def login_email(
language='ru',
)
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
user.email_verified_at = datetime.utcnow()
await db.commit()
else:
raise HTTPException(
@@ -860,17 +783,12 @@ async def login_email(
detail='User account is not active',
)
user.cabinet_last_login = datetime.now(UTC)
user.cabinet_last_login = datetime.utcnow()
await db.commit()
response = _create_auth_response(user)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
if response.campaign_bonus:
response.user = _user_to_response(user)
return response
@@ -953,7 +871,7 @@ async def logout(
token_record = result.scalar_one_or_none()
if token_record:
token_record.revoked_at = datetime.now(UTC)
token_record.revoked_at = datetime.utcnow()
await db.commit()
return {'message': 'Logged out successfully'}
+2 -2
View File
@@ -1,7 +1,7 @@
"""Contests routes for cabinet - user participation in games/contests."""
import random
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any
import structlog
@@ -102,7 +102,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
+2 -2
View File
@@ -1,6 +1,6 @@
"""Notification settings routes for cabinet."""
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -112,7 +112,7 @@ async def update_notification_settings(
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
+6 -19
View File
@@ -24,7 +24,7 @@ from ..auth.oauth_providers import (
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
from .auth import _create_auth_response, _store_refresh_token
logger = structlog.get_logger(__name__)
@@ -32,22 +32,12 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(
db: AsyncSession,
user: User,
provider: str,
campaign_slug: str | None = None,
) -> AuthResponse:
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC)
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
if auth_response.campaign_bonus:
from .auth import _user_to_response
auth_response.user = _user_to_response(user)
return auth_response
@@ -71,9 +61,6 @@ class OAuthAuthorizeResponse(BaseModel):
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
# --- Endpoints ---
@@ -153,7 +140,7 @@ async def oauth_callback(
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
@@ -161,7 +148,7 @@ async def oauth_callback(
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_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, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider)
# 7. Create new user
user = await create_user_by_oauth(
@@ -175,4 +162,4 @@ async def oauth_callback(
username=user_info.username,
)
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
return await _finalize_oauth_login(db, user, provider)
-162
View File
@@ -1,162 +0,0 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import AdvertisingCampaign, User
from app.services.partner_application_service import partner_application_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerCampaignInfo,
PartnerStatusResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
def _get_campaign_deep_link(start_parameter: str) -> str | None:
"""Generate Telegram deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return None
def _get_campaign_web_link(start_parameter: str) -> str | None:
"""Generate web link for campaign."""
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
if base_url:
return f'{base_url}/?campaign={start_parameter}'
return None
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
# Fetch campaigns assigned to this partner
campaigns: list[PartnerCampaignInfo] = []
if user.is_partner:
result = await db.execute(
select(AdvertisingCampaign).where(
AdvertisingCampaign.partner_user_id == user.id,
AdvertisingCampaign.is_active.is_(True),
)
)
for c in result.scalars().all():
campaigns.append(
PartnerCampaignInfo(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
bonus_type=c.bonus_type,
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
subscription_duration_days=c.subscription_duration_days,
subscription_traffic_gb=c.subscription_traffic_gb,
deep_link=_get_campaign_deep_link(c.start_parameter),
web_link=_get_campaign_web_link(c.start_parameter),
)
)
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
campaigns=campaigns,
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о новой заявке
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_partner_application_notification(
user=user,
application_data={
'company_name': request.company_name,
'telegram_channel': request.telegram_channel,
'website_url': request.website_url,
'description': request.description,
'expected_monthly_referrals': request.expected_monthly_referrals,
},
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for partner application', error=e)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)
+3 -3
View File
@@ -1,6 +1,6 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
from datetime import UTC, datetime
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
@@ -247,7 +247,7 @@ async def start_poll(
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.now(UTC)
response.started_at = datetime.utcnow()
await db.commit()
# Get next unanswered question
@@ -346,7 +346,7 @@ async def answer_question(
)
# Poll completed
response.completed_at = datetime.now(UTC)
response.completed_at = datetime.utcnow()
await db.commit()
# Award reward if any
+5 -5
View File
@@ -1,6 +1,6 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any
import structlog
@@ -112,7 +112,7 @@ async def get_promo_offers(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available promo offers for the user."""
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer)
@@ -151,7 +151,7 @@ async def get_active_discount(
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.now(UTC)
now = datetime.utcnow()
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
@@ -284,7 +284,7 @@ async def claim_promo_offer(
detail='Offer not found',
)
now = datetime.now(UTC)
now = datetime.utcnow()
if offer.claimed_at is not None:
raise HTTPException(
@@ -408,7 +408,7 @@ async def clear_active_discount(
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
+5 -21
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import AdvertisingCampaign, ReferralEarning, User
from app.database.models import ReferralEarning, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
@@ -150,26 +150,12 @@ async def get_referral_earnings(
result = await db.execute(query)
earnings = result.scalars().all()
# Batch-fetch referral users to avoid N+1
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
if referral_ids:
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
else:
referral_users_map = {}
# Batch-fetch campaigns to avoid N+1
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
if campaign_ids:
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
else:
campaigns_map = {}
items = []
for e in earnings:
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
items.append(
ReferralEarningResponse(
@@ -179,7 +165,6 @@ async def get_referral_earnings(
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
campaign_name=campaign.name if campaign else None,
created_at=e.created_at,
)
)
@@ -209,5 +194,4 @@ async def get_referral_terms():
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
)
+37 -32
View File
@@ -135,7 +135,7 @@ def _subscription_to_response(
traffic_purchases: list[dict[str, Any]] | None = None,
) -> SubscriptionData:
"""Convert Subscription model to response."""
now = datetime.now(UTC)
now = datetime.utcnow()
# Use actual_status property for correct status (same as bot uses)
actual_status = subscription.actual_status
@@ -190,15 +190,11 @@ def _subscription_to_response(
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
is_daily = getattr(subscription.tariff, 'is_daily', False)
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
traffic_reset_mode = None
# Get daily_price_kopeks and tariff_name from tariff (separate from is_daily check)
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None)
traffic_reset_mode = (
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
)
# Calculate next daily charge time (24 hours after last charge)
next_daily_charge_at = None
@@ -239,7 +235,6 @@ def _subscription_to_response(
next_daily_charge_at=next_daily_charge_at,
tariff_id=tariff_id,
tariff_name=tariff_name,
traffic_reset_mode=traffic_reset_mode,
)
@@ -281,7 +276,7 @@ async def get_subscription(
traffic_purchases_data = []
from app.database.models import TrafficPurchase
now = datetime.now(UTC)
now = datetime.utcnow()
purchases_query = (
select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == fresh_user.subscription.id)
@@ -520,7 +515,7 @@ async def renew_subscription(
user.promo_offer_discount_expires_at = None
# Extend from end_date or now if expired
now = datetime.now(UTC)
now = datetime.utcnow()
if user.subscription.end_date and user.subscription.end_date > now:
user.subscription.end_date = user.subscription.end_date + timedelta(days=request.period_days)
else:
@@ -837,7 +832,9 @@ async def purchase_traffic(
# Устанавливаем дату сброса трафика (только при первой докупке)
# При повторной докупке дата НЕ продлевается
if not subscription.traffic_reset_at:
subscription.traffic_reset_at = datetime.now(UTC) + timedelta(days=30)
from datetime import timedelta
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
logger.info(
'Set traffic_reset_at for subscription',
subscription_id=subscription.id,
@@ -1140,7 +1137,7 @@ async def get_trial_info(
# Check if user already has an active subscription
if user.subscription:
now = datetime.now(UTC)
now = datetime.utcnow()
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
@@ -1197,7 +1194,7 @@ async def activate_trial(
# Check if user already has an active subscription
if user.subscription:
now = datetime.now(UTC)
now = datetime.utcnow()
is_active = (
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
)
@@ -1487,8 +1484,6 @@ async def _build_tariff_response(
# Дневной тариф
'is_daily': getattr(tariff, 'is_daily', False),
'daily_price_kopeks': daily_price,
# Сброс трафика
'traffic_reset_mode': tariff.traffic_reset_mode or settings.DEFAULT_TRAFFIC_RESET_STRATEGY,
}
# Add promo group info if user has discounts
@@ -2015,7 +2010,7 @@ async def purchase_tariff(
# For daily tariffs, set last_daily_charge_at
if is_daily_tariff:
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.last_daily_charge_at = datetime.utcnow()
subscription.is_daily_paused = False
await db.commit()
await db.refresh(subscription)
@@ -2093,7 +2088,7 @@ async def purchase_tariff(
try:
# Determine if this is a new subscription or extension
was_new_subscription = (
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
)
notification_type = (
NotificationType.SUBSCRIPTION_ACTIVATED
@@ -2129,7 +2124,7 @@ async def purchase_tariff(
notification_service = AdminNotificationService(bot)
# Определяем тип покупки: новая подписка или продление
was_new_subscription = (
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
)
await notification_service.send_subscription_purchase_notification(
db=db,
@@ -2216,6 +2211,8 @@ async def purchase_devices(
)
# Calculate prorated price based on remaining days
from datetime import datetime
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
@@ -2630,6 +2627,8 @@ async def get_device_price(
}
# Calculate prorated price
from datetime import datetime
now = datetime.now(UTC)
end_date = subscription.end_date
if end_date.tzinfo is None:
@@ -3080,7 +3079,9 @@ async def get_available_countries(
connected_squads = user.subscription.connected_squads or []
# Calculate days left for prorated pricing
if user.subscription.end_date:
delta = user.subscription.end_date - datetime.now(UTC)
from datetime import datetime
delta = user.subscription.end_date - datetime.utcnow()
days_left = max(0, delta.days)
# Get discount from promo group
@@ -3262,7 +3263,7 @@ async def update_countries(
# Update connected squads
user.subscription.connected_squads = selected_countries
user.subscription.updated_at = datetime.now(UTC)
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Sync with RemnaWave
@@ -3909,7 +3910,7 @@ async def reduce_devices(
# Update subscription
subscription.device_limit = new_device_limit
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
# Update RemnaWave
@@ -4009,8 +4010,8 @@ async def preview_tariff_switch(
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
remaining_days = max(0, delta.days)
# Calculate switch cost
@@ -4127,6 +4128,8 @@ async def switch_tariff(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Switch to a different tariff without changing end date."""
from datetime import timedelta
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -4192,8 +4195,8 @@ async def switch_tariff(
# Calculate remaining days
remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC)
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
delta = user.subscription.end_date - datetime.utcnow()
remaining_days = max(0, delta.days)
# Calculate cost
@@ -4331,14 +4334,14 @@ async def switch_tariff(
if switching_to_daily:
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.is_daily_paused = False
elif switching_from_daily:
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days)
user.subscription.is_daily_paused = False
user.subscription.updated_at = datetime.now(UTC)
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Sync with RemnaWave
@@ -4422,6 +4425,8 @@ async def toggle_subscription_pause(
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Toggle pause/resume for daily subscription."""
from datetime import timedelta
await db.refresh(user, ['subscription'])
if not user.subscription:
@@ -4471,8 +4476,8 @@ async def toggle_subscription_pause(
# Restore ACTIVE status if was DISABLED
if was_disabled:
user.subscription.status = SubscriptionStatus.ACTIVE.value
user.subscription.last_daily_charge_at = datetime.now(UTC)
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
user.subscription.last_daily_charge_at = datetime.utcnow()
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
await db.commit()
await db.refresh(user.subscription)
@@ -4609,7 +4614,7 @@ async def switch_traffic_package(
user.subscription.traffic_limit_gb = new_traffic
user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
user.subscription.traffic_reset_at = None # Reset traffic reset date
user.subscription.updated_at = datetime.now(UTC)
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Sync with RemnaWave
@@ -4728,7 +4733,7 @@ async def refresh_traffic(
used_gb = traffic_stats.get('used_traffic_gb', 0)
if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01:
user.subscription.traffic_used_gb = used_gb
user.subscription.updated_at = datetime.now(UTC)
user.subscription.updated_at = datetime.utcnow()
await db.commit()
# Calculate percentage
+6 -6
View File
@@ -1,7 +1,7 @@
"""Support tickets routes for cabinet."""
import math
from datetime import UTC, datetime
from datetime import datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -137,8 +137,8 @@ async def create_ticket(
title=request.title,
status='open',
priority='normal',
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(ticket)
await db.flush()
@@ -152,7 +152,7 @@ async def create_ticket(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.now(UTC),
created_at=datetime.utcnow(),
)
db.add(message)
await db.commit()
@@ -268,14 +268,14 @@ async def add_ticket_message(
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.now(UTC),
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == 'answered':
ticket.status = 'pending'
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
-166
View File
@@ -1,166 +0,0 @@
"""User-facing withdrawal routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.withdrawals import (
WithdrawalBalanceResponse,
WithdrawalCreateRequest,
WithdrawalCreateResponse,
WithdrawalItemResponse,
WithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
@router.get('/balance', response_model=WithdrawalBalanceResponse)
async def get_withdrawal_balance(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get withdrawal balance stats for current user."""
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
return WithdrawalBalanceResponse(
total_earned=stats['total_earned'],
referral_spent=stats['referral_spent'],
withdrawn=stats['withdrawn'],
pending=stats['pending'],
available_referral=stats['available_referral'],
available_total=stats['available_total'],
only_referral_mode=stats['only_referral_mode'],
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
can_request=can_request,
cannot_request_reason=reason if not can_request else None,
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
)
@router.post('/create', response_model=WithdrawalCreateResponse)
async def create_withdrawal(
request: WithdrawalCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a withdrawal request."""
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
# Уведомляем админов о запросе на вывод
try:
from aiogram import Bot
from app.services.admin_notification_service import AdminNotificationService
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
bot = Bot(token=settings.BOT_TOKEN)
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_withdrawal_request_notification(
user=user,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
finally:
await bot.session.close()
except Exception as e:
logger.error('Failed to send admin notification for withdrawal request', error=e)
return WithdrawalCreateResponse(
id=withdrawal.id,
amount_kopeks=withdrawal.amount_kopeks,
status=withdrawal.status,
)
@router.get('/history', response_model=WithdrawalListResponse)
async def get_withdrawal_history(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's withdrawal request history."""
count_result = await db.execute(
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
)
total = count_result.scalar() or 0
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.user_id == user.id)
.order_by(desc(WithdrawalRequest.created_at))
.limit(50)
)
requests = result.scalars().all()
items = [
WithdrawalItemResponse(
id=r.id,
amount_kopeks=r.amount_kopeks,
amount_rubles=r.amount_kopeks / 100,
status=r.status,
payment_details=r.payment_details,
admin_comment=r.admin_comment,
created_at=r.created_at,
processed_at=r.processed_at,
)
for r in requests
]
return WithdrawalListResponse(items=items, total=total)
@router.post('/{request_id}/cancel')
async def cancel_withdrawal(
request_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Cancel a pending withdrawal request."""
result = await db.execute(
select(WithdrawalRequest)
.where(
WithdrawalRequest.id == request_id,
WithdrawalRequest.user_id == user.id,
)
.with_for_update()
)
withdrawal = result.scalar_one_or_none()
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Можно отменить только заявку в ожидании',
)
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
await db.commit()
return {'success': True}
-23
View File
@@ -9,9 +9,6 @@ class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class TelegramWidgetAuthRequest(BaseModel):
@@ -24,9 +21,6 @@ class TelegramWidgetAuthRequest(BaseModel):
photo_url: str | None = Field(None, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class EmailRegisterRequest(BaseModel):
@@ -40,9 +34,6 @@ class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class EmailLoginRequest(BaseModel):
@@ -50,9 +41,6 @@ class EmailLoginRequest(BaseModel):
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class RefreshTokenRequest(BaseModel):
@@ -114,16 +102,6 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class CampaignBonusInfo(BaseModel):
"""Info about campaign bonus applied during auth."""
campaign_name: str
bonus_type: str
balance_kopeks: int = 0
subscription_days: int | None = None
tariff_name: str | None = None
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
@@ -132,7 +110,6 @@ class AuthResponse(BaseModel):
token_type: str = 'bearer'
expires_in: int
user: UserResponse
campaign_bonus: CampaignBonusInfo | None = None
class RegisterResponse(BaseModel):
-1
View File
@@ -114,7 +114,6 @@ class BroadcastResponse(BaseModel):
total_count: int
sent_count: int
failed_count: int
blocked_count: int = 0
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
admin_id: int | None = None
admin_name: str | None = None
+2 -21
View File
@@ -27,8 +27,6 @@ class CampaignListItem(BaseModel):
registrations_count: int
total_revenue_kopeks: int = 0
conversion_rate: float = 0.0
partner_user_id: int | None = None
partner_name: str | None = None
created_at: datetime
class Config:
@@ -62,16 +60,12 @@ class CampaignDetailResponse(BaseModel):
tariff_id: int | None = None
tariff_duration_days: int | None = None
tariff: TariffInfo | None = None
# Partner
partner_user_id: int | None = None
partner_name: str | None = None
# Meta
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
# Deep link
deep_link: str | None = None
web_link: str | None = None
class Config:
from_attributes = True
@@ -81,7 +75,7 @@ class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType
is_active: bool = True
# Balance bonus
@@ -94,15 +88,13 @@ class CampaignCreateRequest(BaseModel):
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignUpdateRequest(BaseModel):
"""Request to update a campaign."""
name: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType | None = None
is_active: bool | None = None
# Balance bonus
@@ -115,8 +107,6 @@ class CampaignUpdateRequest(BaseModel):
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
# Partner
partner_user_id: int | None = None
class CampaignToggleResponse(BaseModel):
@@ -157,7 +147,6 @@ class CampaignStatisticsResponse(BaseModel):
trial_conversion_rate: float = 0.0
# Deep link
deep_link: str | None = None
web_link: str | None = None
class CampaignRegistrationItem(BaseModel):
@@ -205,14 +194,6 @@ class CampaignsOverviewResponse(BaseModel):
total_tariff_issued: int = 0
class AvailablePartnerItem(BaseModel):
"""Partner item for campaign partner selector."""
user_id: int
username: str | None = None
first_name: str | None = None
class ServerSquadInfo(BaseModel):
"""Server squad info for campaign selection."""
-162
View File
@@ -1,162 +0,0 @@
"""Partner system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class PartnerApplicationRequest(BaseModel):
"""Request to apply for partner status."""
company_name: str | None = Field(None, max_length=255)
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
class PartnerApplicationInfo(BaseModel):
"""Application info for the user."""
id: int
status: str
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class PartnerCampaignInfo(BaseModel):
"""Campaign info visible to the partner."""
id: int
name: str
start_parameter: str
bonus_type: str
balance_bonus_kopeks: int = 0
subscription_duration_days: int | None = None
subscription_traffic_gb: int | None = None
deep_link: str | None = None
web_link: str | None = None
class PartnerStatusResponse(BaseModel):
"""Partner status for current user."""
partner_status: str
commission_percent: int | None = None
latest_application: PartnerApplicationInfo | None = None
campaigns: list[PartnerCampaignInfo] = []
# ==================== Admin-facing ====================
class AdminPartnerApplicationItem(BaseModel):
"""Partner application in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
status: str
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminPartnerApplicationsResponse(BaseModel):
"""List of partner applications."""
items: list[AdminPartnerApplicationItem]
total: int
class AdminApproveRequest(BaseModel):
"""Request to approve a partner application."""
commission_percent: int = Field(..., ge=1, le=100)
comment: str | None = Field(None, max_length=2000)
class AdminRejectRequest(BaseModel):
"""Request to reject a partner application."""
comment: str | None = Field(None, max_length=2000)
class AdminPartnerItem(BaseModel):
"""Partner in admin list."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
total_referrals: int = 0
total_earnings_kopeks: int = 0
balance_kopeks: int = 0
partner_status: str
created_at: datetime
class AdminPartnerListResponse(BaseModel):
"""List of partners for admin."""
items: list[AdminPartnerItem]
total: int
class CampaignSummary(BaseModel):
"""Campaign summary for partner detail."""
id: int
name: str
start_parameter: str
is_active: bool
class AdminPartnerDetailResponse(BaseModel):
"""Detailed partner info for admin."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
partner_status: str
balance_kopeks: int = 0
total_referrals: int = 0
paid_referrals: int = 0
active_referrals: int = 0
earnings_all_time: int = 0
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
conversion_to_paid: float = 0.0
campaigns: list[CampaignSummary] = []
created_at: datetime
class AdminUpdateCommissionRequest(BaseModel):
"""Request to update partner commission."""
commission_percent: int = Field(..., ge=1, le=100)
-2
View File
@@ -47,7 +47,6 @@ class ReferralEarningResponse(BaseModel):
reason: str
referral_username: str | None = None
referral_first_name: str | None = None
campaign_name: str | None = None
created_at: datetime
class Config:
@@ -77,4 +76,3 @@ class ReferralTermsResponse(BaseModel):
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
partner_section_visible: bool = True
-1
View File
@@ -56,7 +56,6 @@ class SubscriptionData(BaseModel):
next_daily_charge_at: datetime | None = None # When next daily charge will happen
tariff_id: int | None = None
tariff_name: str | None = None
traffic_reset_mode: str | None = None
class Config:
from_attributes = True
-129
View File
@@ -1,129 +0,0 @@
"""Withdrawal system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class WithdrawalBalanceResponse(BaseModel):
"""Withdrawal balance info for user."""
total_earned: int
referral_spent: int
withdrawn: int
pending: int
available_referral: int
available_total: int
only_referral_mode: bool
min_amount_kopeks: int
is_withdrawal_enabled: bool
can_request: bool
cannot_request_reason: str | None = None
requisites_text: str = ''
class WithdrawalCreateRequest(BaseModel):
"""Request to create a withdrawal."""
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
payment_details: str = Field(..., min_length=5, max_length=1000)
class WithdrawalItemResponse(BaseModel):
"""Withdrawal request item."""
id: int
amount_kopeks: int
amount_rubles: float
status: str
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class WithdrawalListResponse(BaseModel):
"""List of user's withdrawal requests."""
items: list[WithdrawalItemResponse]
total: int
class WithdrawalCreateResponse(BaseModel):
"""Response after creating withdrawal."""
id: int
amount_kopeks: int
status: str
# ==================== Admin-facing ====================
class AdminWithdrawalItem(BaseModel):
"""Withdrawal request in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminWithdrawalListResponse(BaseModel):
"""List of withdrawal requests for admin."""
items: list[AdminWithdrawalItem]
total: int
pending_count: int = 0
pending_total_kopeks: int = 0
class AdminWithdrawalDetailResponse(BaseModel):
"""Detailed withdrawal request for admin."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
risk_analysis: dict | None = None
payment_details: str | None = None
admin_comment: str | None = None
balance_kopeks: int = 0
total_referrals: int = 0
total_earnings_kopeks: int = 0
created_at: datetime
processed_at: datetime | None = None
class AdminApproveWithdrawalRequest(BaseModel):
"""Request to approve a withdrawal."""
comment: str | None = Field(None, max_length=2000)
class AdminRejectWithdrawalRequest(BaseModel):
"""Request to reject a withdrawal."""
comment: str | None = Field(None, max_length=2000)
@@ -4,7 +4,7 @@ Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -124,7 +124,7 @@ async def save_template_override(
)
row = existing.fetchone()
now = datetime.now(UTC)
now = datetime.utcnow()
if row:
# Update
+5 -253
View File
@@ -4,7 +4,6 @@ Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua, fa
"""
import html
from typing import Any
from app.config import settings
@@ -54,10 +53,6 @@ class EmailNotificationTemplates:
NotificationType.WARNING_NOTIFICATION: self._warning_template,
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
NotificationType.PARTNER_APPLICATION_APPROVED: self._partner_approved_template,
NotificationType.PARTNER_APPLICATION_REJECTED: self._partner_rejected_template,
NotificationType.WITHDRAWAL_APPROVED: self._withdrawal_approved_template,
NotificationType.WITHDRAWAL_REJECTED: self._withdrawal_rejected_template,
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
@@ -533,7 +528,7 @@ class EmailNotificationTemplates:
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for failed autopay notification."""
reason = html.escape(context.get('reason', ''))
reason = context.get('reason', '')
subjects = {
'ru': 'Ошибка автопродления',
@@ -720,7 +715,7 @@ class EmailNotificationTemplates:
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for ban notification."""
reason = html.escape(context.get('reason', ''))
reason = context.get('reason', '')
subjects = {
'ru': 'Аккаунт заблокирован',
@@ -788,7 +783,7 @@ class EmailNotificationTemplates:
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for warning notification."""
message = html.escape(context.get('message', ''))
message = context.get('message', '')
subjects = {
'ru': 'Предупреждение',
@@ -824,7 +819,7 @@ class EmailNotificationTemplates:
def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for referral bonus notification."""
bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f}')
referral_name = html.escape(context.get('referral_name', ''))
referral_name = context.get('referral_name', '')
subjects = {
'ru': f'Реферальный бонус: +{bonus}',
@@ -861,7 +856,7 @@ class EmailNotificationTemplates:
def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for new referral registered notification."""
referral_name = html.escape(context.get('referral_name', ''))
referral_name = context.get('referral_name', '')
subjects = {
'ru': 'Новый реферал зарегистрирован',
@@ -894,249 +889,6 @@ class EmailNotificationTemplates:
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Partner Templates
# ============================================================================
def _partner_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application approved notification."""
commission = context.get('commission_percent', 0)
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство одобрена',
'en': 'Partner Application Approved',
'zh': '合作伙伴申请已批准',
'ua': 'Заявка на партнерство схвалена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство одобрена!</h2>
<div class="highlight success">
<p>Ваша заявка на партнёрство была одобрена.</p>
<p>Ваша комиссия: <strong>{commission}%</strong></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Теперь вы можете приглашать пользователей и получать вознаграждение!</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Approved!</h2>
<div class="highlight success">
<p>Your partner application has been approved.</p>
<p>Your commission rate: <strong>{commission}%</strong></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>You can now invite users and earn rewards!</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请已批准</h2>
<div class="highlight success">
<p>您的合作伙伴申请已获批准</p>
<p>您的佣金比例: <strong>{commission}%</strong></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>您现在可以邀请用户并获得奖励</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство схвалена!</h2>
<div class="highlight success">
<p>Вашу заявку на партнерство було схвалено.</p>
<p>Ваша комісія: <strong>{commission}%</strong></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Тепер ви можете запрошувати користувачів та отримувати винагороду!</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _partner_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for partner application rejected notification."""
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': 'Заявка на партнёрство отклонена',
'en': 'Partner Application Rejected',
'zh': '合作伙伴申请被拒绝',
'ua': 'Заявка на партнерство відхилена',
}
bodies = {
'ru': f"""
<h2>Заявка на партнёрство отклонена</h2>
<div class="highlight danger">
<p>К сожалению, ваша заявка на партнёрство была отклонена.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Вы можете подать новую заявку позже.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Partner Application Rejected</h2>
<div class="highlight danger">
<p>Unfortunately, your partner application has been rejected.</p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>You can submit a new application later.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>合作伙伴申请被拒绝</h2>
<div class="highlight danger">
<p>很抱歉您的合作伙伴申请已被拒绝</p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>您可以稍后提交新的申请</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Заявка на партнерство відхилена</h2>
<div class="highlight danger">
<p>На жаль, вашу заявку на партнерство було відхилено.</p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Ви можете подати нову заявку пізніше.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Withdrawal Templates
# ============================================================================
def _withdrawal_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal approved notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} одобрен',
'en': f'Withdrawal request for {amount} approved',
'zh': f'提现请求 {amount} 已批准',
'ua': f'Запит на виведення {amount} схвалено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод одобрен!</h2>
<div class="highlight success">
<p>Ваш запрос на вывод средств одобрен.</p>
<p>Сумма: <span class="amount">{amount}</span></p>
{f'<p>Комментарий: {comment}</p>' if comment else ''}
</div>
<p>Средства будут переведены в ближайшее время.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Approved!</h2>
<div class="highlight success">
<p>Your withdrawal request has been approved.</p>
<p>Amount: <span class="amount">{amount}</span></p>
{f'<p>Comment: {comment}</p>' if comment else ''}
</div>
<p>Funds will be transferred shortly.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求已批准</h2>
<div class="highlight success">
<p>您的提现请求已获批准</p>
<p>金额: <span class="amount">{amount}</span></p>
{f'<p>备注: {comment}</p>' if comment else ''}
</div>
<p>资金将很快转入</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення схвалено!</h2>
<div class="highlight success">
<p>Ваш запит на виведення коштів було схвалено.</p>
<p>Сума: <span class="amount">{amount}</span></p>
{f'<p>Коментар: {comment}</p>' if comment else ''}
</div>
<p>Кошти будуть переведені найближчим часом.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
def _withdrawal_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
"""Template for withdrawal rejected notification."""
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f}')
comment = html.escape(context.get('comment', ''))
subjects = {
'ru': f'Запрос на вывод {amount} отклонён',
'en': f'Withdrawal request for {amount} rejected',
'zh': f'提现请求 {amount} 被拒绝',
'ua': f'Запит на виведення {amount} відхилено',
}
bodies = {
'ru': f"""
<h2>Запрос на вывод отклонён</h2>
<div class="highlight danger">
<p>Ваш запрос на вывод средств был отклонён.</p>
<p>Сумма: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Средства возвращены на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
'en': f"""
<h2>Withdrawal Request Rejected</h2>
<div class="highlight danger">
<p>Your withdrawal request has been rejected.</p>
<p>Amount: <strong>{amount}</strong></p>
{f'<p>Reason: {comment}</p>' if comment else ''}
</div>
<p>Funds have been returned to your balance.</p>
{self._get_cabinet_button(language)}
""",
'zh': f"""
<h2>提现请求被拒绝</h2>
<div class="highlight danger">
<p>您的提现请求已被拒绝</p>
<p>金额: <strong>{amount}</strong></p>
{f'<p>原因: {comment}</p>' if comment else ''}
</div>
<p>资金已退回您的余额</p>
{self._get_cabinet_button(language)}
""",
'ua': f"""
<h2>Запит на виведення відхилено</h2>
<div class="highlight danger">
<p>Ваш запит на виведення коштів було відхилено.</p>
<p>Сума: <strong>{amount}</strong></p>
{f'<p>Причина: {comment}</p>' if comment else ''}
</div>
<p>Кошти повернуто на ваш баланс.</p>
{self._get_cabinet_button(language)}
""",
}
return {
'subject': subjects.get(language, subjects['ru']),
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
}
# ============================================================================
# Payment Templates
# ============================================================================
-4
View File
@@ -230,9 +230,7 @@ class Settings(BaseSettings):
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
REFERRAL_WITHDRAWAL_REQUISITES_TEXT: str = '' # Текст-подсказка для реквизитов при выводе
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений
REFERRAL_PARTNER_SECTION_VISIBLE: bool = True # Показывать раздел партнёрки в кабинете
# Настройки анализа на подозрительность
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
@@ -551,7 +549,6 @@ class Settings(BaseSettings):
LOG_LEVEL: str = 'INFO'
LOG_FILE: str = 'logs/bot.log'
LOG_COLORS: bool = True # ANSI-цвета в консоли (false для plain-text вывода)
# === Log Rotation Settings ===
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
@@ -673,7 +670,6 @@ class Settings(BaseSettings):
WEB_API_DEFAULT_TOKEN: str | None = None
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
+2 -2
View File
@@ -8,7 +8,7 @@ from .database import (
get_db,
get_db_read_only,
get_pool_metrics,
sync_postgres_sequences,
init_db,
)
@@ -20,5 +20,5 @@ __all__ = [
'get_db',
'get_db_read_only',
'get_pool_metrics',
'sync_postgres_sequences',
'init_db',
]
+63 -8
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import and_, delete, func, select, update
@@ -36,7 +36,6 @@ async def create_campaign(
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
is_active: bool = True,
partner_user_id: int | None = None,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -51,7 +50,6 @@ async def create_campaign(
tariff_duration_days=tariff_duration_days,
created_by=created_by,
is_active=is_active,
partner_user_id=partner_user_id,
)
db.add(campaign)
@@ -73,7 +71,6 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.where(AdvertisingCampaign.id == campaign_id)
)
@@ -106,7 +103,6 @@ async def get_campaigns_list(
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
selectinload(AdvertisingCampaign.partner),
)
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
@@ -145,7 +141,6 @@ async def update_campaign(
'tariff_id',
'tariff_duration_days',
'is_active',
'partner_user_id',
}
update_data = {}
@@ -156,7 +151,7 @@ async def update_campaign(
if not update_data:
return campaign
update_data['updated_at'] = datetime.now(UTC)
update_data['updated_at'] = datetime.utcnow()
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
await db.commit()
@@ -336,7 +331,7 @@ async def get_campaign_statistics(
first_payment_time_by_user[user_id] = converted_at
for user_id, amount_kopeks, created_at in subscription_payments:
amount_value = abs(int(amount_kopeks or 0))
amount_value = int(amount_kopeks or 0)
subscription_payments_total += amount_value
paid_users_from_transactions.add(user_id)
@@ -364,6 +359,66 @@ async def get_campaign_statistics(
if first_payment_amount_by_user:
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
total_revenue = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_count = paid_users_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -127,7 +127,7 @@ async def update_cloudpayments_payment(
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
@@ -171,7 +171,7 @@ async def mark_cloudpayments_payment_as_paid(
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.paid_at = datetime.utcnow()
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
@@ -190,7 +190,7 @@ async def mark_cloudpayments_payment_as_paid(
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
+3 -3
View File
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import and_, delete, desc, select
@@ -107,7 +107,7 @@ async def create_round(
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
@@ -124,7 +124,7 @@ async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
+6 -4
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime
import structlog
from sqlalchemy import and_, select
@@ -76,7 +76,7 @@ async def update_cryptobot_payment_status(
return None
payment.status = status
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
if status == 'paid' and paid_at:
payment.paid_at = paid_at
@@ -97,7 +97,7 @@ async def link_cryptobot_payment_to_transaction(
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
@@ -120,7 +120,9 @@ async def get_user_cryptobot_payments(
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
+5 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from sqlalchemy import func, select
@@ -28,7 +28,7 @@ async def upsert_discount_offer(
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
result = await db.execute(
select(DiscountOffer)
@@ -116,7 +116,7 @@ async def list_active_discount_offers_for_user(
) -> list[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.now(UTC)
now = datetime.utcnow()
stmt = (
select(DiscountOffer)
.options(
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
*,
details: dict | None = None,
) -> DiscountOffer:
offer.claimed_at = datetime.now(UTC)
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
@@ -190,7 +190,7 @@ async def mark_offer_claimed(
async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True,
+4 -4
View File
@@ -1,5 +1,5 @@
from collections.abc import Iterable
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import delete, func, select, update
@@ -21,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.now(UTC)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
@@ -117,7 +117,7 @@ async def update_faq_page(
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.now(UTC)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
@@ -139,6 +139,6 @@ async def bulk_update_order(
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.now(UTC))
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+4 -4
View File
@@ -1,7 +1,7 @@
"""CRUD операции для платежей Freekassa."""
import json
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select
@@ -77,10 +77,10 @@ async def update_freekassa_payment_status(
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.now(UTC)
payment.paid_at = datetime.utcnow()
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
@@ -134,7 +134,7 @@ async def get_expired_pending_payments(
db: AsyncSession,
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == 'pending',
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -129,7 +129,7 @@ async def update_heleket_payment(
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
@@ -157,7 +157,7 @@ async def link_heleket_payment_to_transaction(
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
+4 -4
View File
@@ -1,7 +1,7 @@
"""CRUD операции для платежей KassaAI."""
import json
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select
@@ -79,10 +79,10 @@ async def update_kassa_ai_payment_status(
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.now(UTC)
payment.paid_at = datetime.utcnow()
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
@@ -136,7 +136,7 @@ async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> list[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == 'pending',
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select
@@ -90,7 +90,7 @@ async def update_mulenpay_payment_status(
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
@@ -103,7 +103,7 @@ async def update_mulenpay_payment_metadata(
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
@@ -116,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
transaction_id: int,
) -> MulenPayPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -115,7 +115,7 @@ async def update_platega_payment(
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
@@ -129,7 +129,7 @@ async def link_platega_payment_to_transaction(
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select
@@ -26,7 +26,7 @@ async def upsert_privacy_policy(
if policy:
policy.content = content or ''
policy.updated_at = datetime.now(UTC)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
@@ -52,7 +52,7 @@ async def set_privacy_policy_enabled(
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.now(UTC)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
+4 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import UTC, datetime
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -118,13 +118,13 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.now(UTC)
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get('active_discount_hours')
if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours:
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.now(UTC)
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
@@ -204,7 +204,7 @@ async def update_promo_offer_template(
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.now(UTC)
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
+7 -5
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime
import structlog
from sqlalchemy import and_, func, select
@@ -114,7 +114,7 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.utcnow())
db.add(promocode_use)
await db.commit()
@@ -133,7 +133,9 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
cutoff = datetime.now(UTC) - timedelta(hours=hours)
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))
)
@@ -176,7 +178,7 @@ async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) ->
if hasattr(promocode, field):
setattr(promocode, field, value)
promocode.updated_at = datetime.now(UTC)
promocode.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(promocode)
@@ -237,7 +239,7 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
)
total_uses = total_uses_result.scalar()
today = datetime.now(UTC).date()
today = datetime.utcnow().date()
today_uses_result = await db.execute(
select(func.count(PromoCodeUse.id)).where(
and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today)
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select
@@ -26,7 +26,7 @@ async def upsert_public_offer(
if offer:
offer.content = content or ''
offer.updated_at = datetime.now(UTC)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
@@ -52,7 +52,7 @@ async def set_public_offer_enabled(
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.now(UTC)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
+9 -26
View File
@@ -1,27 +1,16 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
from app.database.models import ReferralEarning, User
logger = structlog.get_logger(__name__)
async def get_user_campaign_id(db: AsyncSession, user_id: int) -> int | None:
"""Получить campaign_id первой регистрации пользователя."""
result = await db.execute(
select(AdvertisingCampaignRegistration.campaign_id)
.where(AdvertisingCampaignRegistration.user_id == user_id)
.order_by(AdvertisingCampaignRegistration.created_at.asc())
.limit(1)
)
return result.scalar_one_or_none()
async def create_referral_earning(
db: AsyncSession,
user_id: int,
@@ -29,7 +18,6 @@ async def create_referral_earning(
amount_kopeks: int,
reason: str,
referral_transaction_id: int | None = None,
campaign_id: int | None = None,
) -> ReferralEarning:
earning = ReferralEarning(
user_id=user_id,
@@ -37,7 +25,6 @@ async def create_referral_earning(
amount_kopeks=amount_kopeks,
reason=reason,
referral_transaction_id=referral_transaction_id,
campaign_id=campaign_id,
)
db.add(earning)
@@ -55,11 +42,7 @@ async def get_referral_earnings_by_user(
) -> list[ReferralEarning]:
result = await db.execute(
select(ReferralEarning)
.options(
selectinload(ReferralEarning.referral),
selectinload(ReferralEarning.referral_transaction),
selectinload(ReferralEarning.campaign),
)
.options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction))
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.offset(offset)
@@ -195,7 +178,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
}
)
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
@@ -207,7 +190,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.utcnow() - timedelta(days=7)
week_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
)
@@ -218,7 +201,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.now(UTC) - timedelta(days=30)
month_ago = datetime.utcnow() - timedelta(days=30)
month_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
)
@@ -266,7 +249,7 @@ async def get_top_referrers_by_period(
"""
from app.database.models import Transaction, TransactionType
now = datetime.now(UTC)
now = datetime.utcnow()
if period == 'week':
start_date = now - timedelta(days=7)
else: # month
@@ -397,12 +380,12 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
total_earned = await get_referral_earnings_sum(db, user_id)
month_ago = datetime.now(UTC) - timedelta(days=30)
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
active_referrals_result = await db.execute(
select(func.count(User.id))
+8 -8
View File
@@ -1,5 +1,5 @@
from collections.abc import Sequence
from datetime import UTC, date, datetime, time
from datetime import date, datetime, time
import structlog
from sqlalchemy import and_, desc, func, select
@@ -165,7 +165,7 @@ async def add_contest_event(
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.now(UTC),
occurred_at=datetime.utcnow(),
)
db.add(event)
await db.commit()
@@ -440,7 +440,7 @@ async def get_contest_transaction_breakdown(
# Сумма покупок подписок
subscription_result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
@@ -512,7 +512,7 @@ async def upsert_contest_event(
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.now(UTC),
occurred_at=datetime.utcnow(),
)
db.add(event)
await db.commit()
@@ -621,10 +621,10 @@ async def debug_contest_transactions(
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
)
subscription_in_period = sum(
abs(tx.amount_kopeks) for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
)
total_in_period = deposit_in_period + subscription_in_period
total_outside = sum(abs(tx.amount_kopeks) for tx in txs_out)
total_outside = sum(tx.amount_kopeks for tx in txs_out)
# Подсчёт ПОЛНЫХ сумм (не только sample, БЕЗ бонусов)
full_deposit_result = await db.execute(
@@ -642,7 +642,7 @@ async def debug_contest_transactions(
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
full_subscription_result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
@@ -773,7 +773,7 @@ async def sync_contest_events(
for event in events:
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
subscription_query = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
subscription_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id == event.referral_id,
Transaction.is_completed.is_(True),
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import select, update
@@ -30,7 +30,7 @@ async def create_or_update_rules(
for rule in existing_rules:
rule.is_active = False
rule.updated_at = datetime.now(UTC)
rule.updated_at = datetime.utcnow()
new_rules = ServiceRule(title=title, content=content, language=language, is_active=True, order=0)
@@ -47,7 +47,7 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool:
result = await db.execute(
update(ServiceRule)
.where(ServiceRule.language == language, ServiceRule.is_active == True)
.values(is_active=False, updated_at=datetime.now(UTC))
.values(is_active=False, updated_at=datetime.utcnow())
)
await db.commit()
@@ -110,7 +110,7 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
await db.execute(
update(ServiceRule)
.where(ServiceRule.language == language, ServiceRule.is_active == True)
.values(is_active=False, updated_at=datetime.now(UTC))
.values(is_active=False, updated_at=datetime.utcnow())
)
restored_rule = ServiceRule(
+3 -3
View File
@@ -1,6 +1,6 @@
import random
from collections.abc import Iterable, Sequence
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import (
@@ -360,7 +360,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
# Clean up stale UUIDs from tariff allowed_squads
@@ -373,7 +373,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
filtered = [u for u in current if u not in removed_uuids]
if len(filtered) != len(current):
tariff.allowed_squads = filtered
tariff.updated_at = datetime.now(UTC)
tariff.updated_at = datetime.utcnow()
cleaned_tariffs += 1
logger.info(
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
+68 -75
View File
@@ -17,7 +17,6 @@ from app.database.models import (
SubscriptionStatus,
User,
UserPromoGroup,
UserStatus,
)
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.utils.timezone import format_local_datetime
@@ -32,7 +31,7 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
"""Return True if subscription was updated by webhook within guard window."""
if not subscription.last_webhook_update_at:
return False
elapsed = (datetime.now(UTC) - subscription.last_webhook_update_at).total_seconds()
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
return elapsed < _WEBHOOK_GUARD_SECONDS
@@ -102,13 +101,13 @@ async def create_trial_subscription(
except Exception as error:
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
end_date = datetime.now(UTC) + timedelta(days=duration_days)
end_date = datetime.utcnow() + timedelta(days=duration_days)
# Check for existing PENDING trial subscription (retry after failed payment)
existing = await get_subscription_by_user_id(db, user_id)
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
existing.status = SubscriptionStatus.ACTIVE.value
existing.start_date = datetime.now(UTC)
existing.start_date = datetime.utcnow()
existing.end_date = end_date
existing.traffic_limit_gb = traffic_limit_gb
existing.device_limit = device_limit
@@ -125,7 +124,7 @@ async def create_trial_subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=True,
start_date=datetime.now(UTC),
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
@@ -177,7 +176,7 @@ async def create_paid_subscription(
is_trial: bool = False,
tariff_id: int | None = None,
) -> Subscription:
end_date = datetime.now(UTC) + timedelta(days=duration_days)
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
@@ -186,7 +185,7 @@ async def create_paid_subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=is_trial,
start_date=datetime.now(UTC),
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
@@ -254,7 +253,7 @@ async def replace_subscription(
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
@@ -342,7 +341,7 @@ async def extend_subscription(
device_limit: Лимит устройств (опционально, для режима тарифов)
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
"""
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
logger.info(
@@ -359,41 +358,45 @@ async def extend_subscription(
if is_tariff_change:
logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id)
# Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла
# и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа.
# Если подписка ещё активна - просто добавляем дни к существующей дате окончания.
bonus_days = 0
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
'📅 Срок подписки уменьшен на дней, новая дата окончания', abs=abs(days), end_date=subscription.end_date
)
elif is_tariff_change:
# При СМЕНЕ тарифа сохраняем оставшееся время активной подписки
# Для триалов — только если включена настройка TRIAL_ADD_REMAINING_DAYS_TO_PAID
remaining_seconds = 0
if subscription.end_date and subscription.end_date > current_time:
if not subscription.is_trial or settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
remaining_seconds = max(0, remaining.total_seconds())
logger.info(
'🎁 Обнаружен остаток подписки, будет добавлен к новому сроку',
remaining_seconds=int(remaining_seconds),
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
subscription.end_date = current_time + timedelta(days=days, seconds=remaining_seconds)
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
'🎁 Обнаружен остаток триала: дней для подписки',
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(
'📅 СМЕНА тарифа: срок начинается с текущей даты + дней + остаток',
days=days,
remaining_seconds=int(remaining_seconds),
)
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('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days)
else:
# Подписка истекла - начинаем с текущей даты
subscription.end_date = current_time + timedelta(days=days)
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', days=days)
# Подписка истекла - начинаем с текущей даты + бонус от триала
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Триал истёк, но бонус всё равно не добавляем (триал уже истёк)
pass
total_days = days + bonus_days
subscription.end_date = current_time + timedelta(days=total_days)
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', total_days=total_days)
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
@@ -532,12 +535,16 @@ async def extend_subscription(
async def add_subscription_traffic(db: AsyncSession, subscription: Subscription, gb: int) -> Subscription:
subscription.add_traffic(gb)
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
# Создаём новую запись докупки с индивидуальной датой истечения (30 дней)
from datetime import timedelta
from sqlalchemy import select as sql_select
from app.database.models import TrafficPurchase
new_expires_at = datetime.now(UTC) + timedelta(days=30)
new_expires_at = datetime.utcnow() + timedelta(days=30)
new_purchase = TrafficPurchase(subscription_id=subscription.id, traffic_gb=gb, expires_at=new_expires_at)
db.add(new_purchase)
@@ -546,9 +553,9 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
subscription.purchased_traffic_gb = current_purchased + gb
# Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок
now = datetime.now(UTC)
now = datetime.utcnow()
active_purchases_query = (
select(TrafficPurchase)
sql_select(TrafficPurchase)
.where(TrafficPurchase.subscription_id == subscription.id)
.where(TrafficPurchase.expires_at > now)
)
@@ -578,7 +585,7 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
async def add_subscription_devices(db: AsyncSession, subscription: Subscription, devices: int) -> Subscription:
subscription.device_limit += devices
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -590,7 +597,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
async def add_subscription_squad(db: AsyncSession, subscription: Subscription, squad_uuid: str) -> Subscription:
if squad_uuid not in subscription.connected_squads:
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -605,7 +612,7 @@ async def remove_subscription_squad(db: AsyncSession, subscription: Subscription
squads = subscription.connected_squads.copy()
squads.remove(squad_uuid)
subscription.connected_squads = squads
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -681,7 +688,7 @@ async def update_subscription_autopay(
) -> Subscription:
subscription.autopay_enabled = enabled
subscription.autopay_days_before = days_before
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -693,7 +700,7 @@ async def update_subscription_autopay(
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.DISABLED.value
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -708,7 +715,7 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
Активирует только если подписка была DISABLED и ещё не истекла.
Не логирует если реактивация не требуется.
"""
now = datetime.now(UTC)
now = datetime.utcnow()
# Тихо выходим если реактивация не нужна
if subscription.status != SubscriptionStatus.DISABLED.value:
@@ -727,18 +734,16 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]:
threshold_date = datetime.now(UTC) + timedelta(days=days_before)
threshold_date = datetime.utcnow() + timedelta(days=days_before)
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.end_date <= threshold_date,
Subscription.end_date > datetime.now(UTC),
Subscription.end_date > datetime.utcnow(),
)
)
)
@@ -748,25 +753,17 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.end_date <= datetime.now(UTC),
)
)
.where(and_(Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= datetime.utcnow()))
)
return result.scalars().all()
async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
result = await db.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -774,7 +771,6 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False,
)
@@ -815,7 +811,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
paid_subscriptions = active_subscriptions - trial_subscriptions
today = datetime.now(UTC).date()
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= today, Subscription.is_trial == False)
@@ -823,7 +819,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_today = today_result.scalar()
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.utcnow() - timedelta(days=7)
week_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= week_ago, Subscription.is_trial == False)
@@ -831,7 +827,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_week = week_result.scalar()
month_ago = datetime.now(UTC) - timedelta(days=30)
month_ago = datetime.utcnow() - timedelta(days=30)
month_result = await db.execute(
select(func.count(Subscription.id)).where(
and_(Subscription.created_at >= month_ago, Subscription.is_trial == False)
@@ -884,7 +880,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
async def get_trial_statistics(db: AsyncSession) -> dict:
now = datetime.now(UTC)
now = datetime.utcnow()
total_trials_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)))
total_trials = total_trials_result.scalar() or 0
@@ -917,7 +913,7 @@ async def get_trial_statistics(db: AsyncSession) -> dict:
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(Subscription)
@@ -974,7 +970,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
async def update_subscription_usage(db: AsyncSession, subscription: Subscription, used_gb: float) -> Subscription:
subscription.traffic_used_gb = used_gb
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -1526,7 +1522,7 @@ async def calculate_addon_cost_for_remaining_period(
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = datetime.now(UTC)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
@@ -1536,7 +1532,7 @@ async def expire_subscription(db: AsyncSession, subscription: Subscription) -> S
async def check_and_update_subscription_status(db: AsyncSession, subscription: Subscription) -> Subscription:
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
logger.info(
'🔍 Проверка статуса подписки , текущий статус дата окончания текущее время',
@@ -1599,7 +1595,7 @@ async def create_subscription_no_commit(
"""
if end_date is None:
end_date = datetime.now(UTC) + timedelta(days=3)
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
@@ -1649,7 +1645,7 @@ async def create_subscription(
autopay_days_before: int | None = None,
) -> Subscription:
if end_date is None:
end_date = datetime.now(UTC) + timedelta(days=3)
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
@@ -1697,7 +1693,7 @@ async def create_pending_subscription(
is_trial: If True, marks the subscription as a trial subscription.
"""
trial_label = 'триальная ' if is_trial else ''
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
@@ -1813,7 +1809,7 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
)
# Обновляем статус подписки на ACTIVE
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Если указан период, обновляем дату окончания
@@ -1874,7 +1870,7 @@ async def activate_pending_trial_subscription(
)
# Обновляем статус подписки на ACTIVE
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
pending_subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем даты
@@ -1918,13 +1914,12 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
"""
from app.database.models import Tariff
now = datetime.now(UTC)
now = datetime.utcnow()
one_day_ago = now - timedelta(hours=24)
query = (
select(Subscription)
.join(Tariff, Subscription.tariff_id == Tariff.id)
.join(User, Subscription.user_id == User.id)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
@@ -1934,7 +1929,6 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
Tariff.is_daily.is_(True),
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
Subscription.is_trial.is_(False), # Не списываем с триальных подписок
# Списания ещё не было ИЛИ прошло более 24 часов
@@ -1973,7 +1967,6 @@ async def get_disabled_daily_subscriptions_for_resume(
Tariff.is_daily.is_(True),
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
@@ -2027,8 +2020,8 @@ async def resume_daily_subscription(
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(
'✅ Суточная подписка восстановлена из в ACTIVE',
subscription_id=subscription.id,
@@ -2051,7 +2044,7 @@ async def update_daily_charge_time(
charge_time: datetime = None,
) -> Subscription:
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
now = charge_time or datetime.now(UTC)
now = charge_time or datetime.utcnow()
subscription.last_daily_charge_at = now
# Продлеваем подписку на 1 день от текущего момента
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from sqlalchemy import func, select
@@ -20,7 +20,7 @@ async def create_subscription_conversion(
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.now(UTC),
converted_at=datetime.utcnow(),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
@@ -82,7 +82,7 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
avg_first_payment_result = await db.execute(select(func.avg(SubscriptionConversion.first_payment_amount_kopeks)))
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.now(UTC) - timedelta(days=30)
month_ago = datetime.utcnow() - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(SubscriptionConversion.converted_at >= month_ago)
)
+2 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
from sqlalchemy import and_, func, select
@@ -32,7 +32,7 @@ async def create_subscription_event(
amount_kopeks=amount_kopeks,
currency=currency,
message=message,
occurred_at=occurred_at or datetime.now(UTC),
occurred_at=occurred_at or datetime.utcnow(),
extra=extra or None,
)
db.add(event)
+9 -8
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import and_, desc, func, or_, select, update
@@ -154,17 +154,18 @@ class TicketCRUD:
tickets = result.scalars().all()
if not tickets:
return None
from datetime import datetime
# Если есть вечная блокировка в любом тикете — блок активен без срока
for t in tickets:
if t.user_reply_block_permanent:
return datetime.max.replace(tzinfo=UTC)
return datetime.max
# Иначе ищем максимальный срок блокировки, если он в будущем
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
if not future_until:
return None
max_until = max(future_until)
return max_until if max_until > datetime.now(UTC) else None
return max_until if max_until > datetime.utcnow() else None
@staticmethod
async def get_all_tickets(
@@ -224,7 +225,7 @@ class TicketCRUD:
return False
ticket.status = status
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
if status == TicketStatus.CLOSED.value and closed_at:
ticket.closed_at = closed_at
@@ -258,14 +259,14 @@ class TicketCRUD:
return False
ticket.user_reply_block_permanent = bool(permanent)
ticket.user_reply_block_until = until
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
await db.commit()
return True
@staticmethod
async def close_ticket(db: AsyncSession, ticket_id: int) -> bool:
"""Закрыть тикет"""
return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.now(UTC))
return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow())
@staticmethod
async def close_all_open_tickets(
@@ -279,7 +280,7 @@ class TicketCRUD:
if not ticket_ids:
return []
now = datetime.now(UTC)
now = datetime.utcnow()
await db.execute(
update(Ticket)
.where(Ticket.id.in_(ticket_ids))
@@ -416,7 +417,7 @@ class TicketMessageCRUD:
except Exception:
pass
ticket.updated_at = datetime.now(UTC)
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
+6 -6
View File
@@ -1,6 +1,6 @@
"""CRUD operations for TicketNotification."""
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import desc, func, select, update
@@ -40,7 +40,7 @@ class TicketNotificationCRUD:
message=message,
is_for_admin=is_for_admin,
is_read=False,
created_at=datetime.now(UTC),
created_at=datetime.utcnow(),
)
db.add(notification)
await db.commit()
@@ -130,7 +130,7 @@ class TicketNotificationCRUD:
query = (
update(TicketNotification)
.where(TicketNotification.id == notification_id)
.values(is_read=True, read_at=datetime.now(UTC))
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
@@ -146,7 +146,7 @@ class TicketNotificationCRUD:
TicketNotification.is_for_admin == False,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.now(UTC))
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
@@ -161,7 +161,7 @@ class TicketNotificationCRUD:
TicketNotification.is_for_admin == True,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.now(UTC))
.values(is_read=True, read_at=datetime.utcnow())
)
result = await db.execute(query)
await db.commit()
@@ -178,7 +178,7 @@ class TicketNotificationCRUD:
TicketNotification.ticket_id == ticket_id,
TicketNotification.is_read == False,
)
.values(is_read=True, read_at=datetime.now(UTC))
.values(is_read=True, read_at=datetime.utcnow())
)
if is_admin:
+11 -11
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from sqlalchemy import and_, func, or_, select
@@ -47,7 +47,7 @@ async def create_transaction(
payment_method=payment_method.value if payment_method else None,
external_id=external_id,
is_completed=is_completed,
completed_at=datetime.now(UTC) if is_completed else None,
completed_at=datetime.utcnow() if is_completed else None,
**({'created_at': created_at} if created_at else {}),
)
@@ -150,7 +150,7 @@ async def get_user_transactions_count(
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
@@ -163,7 +163,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
transaction.is_completed = True
transaction.completed_at = datetime.now(UTC)
transaction.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(transaction)
@@ -198,9 +198,9 @@ async def get_transactions_statistics(
db: AsyncSession, start_date: datetime | None = None, end_date: datetime | None = None
) -> dict:
if not start_date:
start_date = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if not end_date:
end_date = datetime.now(UTC)
end_date = datetime.utcnow()
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
income_result = await db.execute(
@@ -229,7 +229,7 @@ async def get_transactions_statistics(
total_expenses = expenses_result.scalar()
subscription_income_result = await db.execute(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
@@ -279,7 +279,7 @@ async def get_transactions_statistics(
row.payment_method: {'count': row.count, 'amount': row.total_amount} for row in payment_methods_result
}
today = datetime.now(UTC).date()
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(Transaction.id)).where(
and_(Transaction.is_completed == True, Transaction.created_at >= today)
@@ -316,7 +316,7 @@ async def get_transactions_statistics(
async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]:
"""Доход по дням - только реальные платежи."""
start_date = datetime.now(UTC) - timedelta(days=days)
start_date = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
@@ -363,7 +363,7 @@ async def find_tribute_transactions_by_payment_id(
async def check_tribute_payment_duplicate(
db: AsyncSession, payment_id: str, amount_kopeks: int, user_telegram_id: int
) -> Transaction | None:
cutoff_time = datetime.now(UTC) - timedelta(hours=24)
cutoff_time = datetime.utcnow() - timedelta(hours=24)
exact_external_id = f'donation_{payment_id}'
@@ -400,7 +400,7 @@ async def create_unique_tribute_transaction(
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
if existing:
timestamp = int(datetime.now(UTC).timestamp())
timestamp = int(datetime.utcnow().timestamp())
external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}'
logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id)
+28 -28
View File
@@ -86,7 +86,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -106,7 +106,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -131,7 +131,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -152,7 +152,7 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Use
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -171,7 +171,7 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -195,7 +195,7 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
if not existing_user:
return code
timestamp = str(int(datetime.now(UTC).timestamp()))[-6:]
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
return f'ref{timestamp}'
@@ -379,7 +379,7 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
if hasattr(user, field):
setattr(user, field, value)
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -399,7 +399,7 @@ async def add_user_balance(
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
@@ -564,7 +564,7 @@ async def subtract_user_balance(
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
if create_transaction:
from app.database.crud.transaction import (
@@ -620,7 +620,7 @@ async def subtract_user_balance(
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
@@ -719,7 +719,7 @@ async def get_users_list(
order_by_purchase_count: bool = False,
) -> list[User]:
query = select(User).options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -879,7 +879,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -899,12 +899,12 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[User]:
now = datetime.now(UTC)
now = datetime.utcnow()
base_query = (
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
@@ -961,12 +961,12 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
result = await db.execute(
select(User)
.options(
selectinload(User.subscription).selectinload(Subscription.tariff),
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
@@ -986,7 +986,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
async def delete_user(db: AsyncSession, user: User) -> bool:
user.status = UserStatus.DELETED.value
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
user_id_display = user.telegram_id or user.email or f'#{user.id}'
@@ -1001,19 +1001,19 @@ async def get_users_statistics(db: AsyncSession) -> dict:
active_result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value))
active_users = active_result.scalar()
today = datetime.now(UTC).date()
today = datetime.utcnow().date()
today_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= today, User.status == UserStatus.ACTIVE.value))
)
new_today = today_result.scalar()
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.utcnow() - timedelta(days=7)
week_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= week_ago, User.status == UserStatus.ACTIVE.value))
)
new_week = week_result.scalar()
month_ago = datetime.now(UTC) - timedelta(days=30)
month_ago = datetime.utcnow() - timedelta(days=30)
month_result = await db.execute(
select(func.count(User.id)).where(and_(User.created_at >= month_ago, User.status == UserStatus.ACTIVE.value))
)
@@ -1037,7 +1037,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
Returns:
Список пользователей с активными подписками и remnawave_uuid
"""
current_time = datetime.now(UTC)
current_time = datetime.utcnow()
result = await db.execute(
select(User)
@@ -1050,7 +1050,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
Subscription.end_date > current_time,
)
)
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
.options(selectinload(User.subscription))
)
return result.scalars().unique().all()
@@ -1177,7 +1177,7 @@ async def set_email_change_pending(
user.email_change_new = new_email
user.email_change_code = code
user.email_change_expires = expires_at
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -1201,7 +1201,7 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
if not user.email_change_new or not user.email_change_code:
return False, 'No pending email change'
if user.email_change_expires and datetime.now(UTC) > user.email_change_expires:
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
# Clear expired data
user.email_change_new = None
user.email_change_code = None
@@ -1227,11 +1227,11 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
# Apply the change
user.email = new_email
user.email_verified = True
user.email_verified_at = datetime.now(UTC)
user.email_verified_at = datetime.utcnow()
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
@@ -1251,7 +1251,7 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
await db.commit()
logger.info('Email change cancelled for user', user_id=user.id)
@@ -1286,7 +1286,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str
return
value: str | int = int(provider_id) if provider == 'vk' else provider_id
setattr(user, column_name, value)
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
+3 -3
View File
@@ -1,5 +1,5 @@
import random
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import func, select
@@ -111,7 +111,7 @@ async def update_user_message(
if sort_order is not None:
message.sort_order = sort_order
message.updated_at = datetime.now(UTC)
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
@@ -127,7 +127,7 @@ async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserM
return None
message.is_active = not message.is_active
message.updated_at = datetime.now(UTC)
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
+2 -2
View File
@@ -1,6 +1,6 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import and_, desc, select
@@ -36,7 +36,7 @@ async def _sync_user_primary_promo_group(
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.now(UTC)
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
+3 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import UTC, datetime
from datetime import datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -71,7 +71,7 @@ async def update_token(
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.now(UTC)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
@@ -86,7 +86,7 @@ async def set_tokens_active_status(
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.now(UTC))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
+4 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -94,7 +94,7 @@ async def update_webhook(
if is_active is not None:
webhook.is_active = is_active
webhook.updated_at = datetime.now(UTC)
webhook.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(webhook)
return webhook
@@ -127,7 +127,7 @@ async def record_webhook_delivery(
response_body=response_body,
error_message=error_message,
attempt_number=attempt_number,
delivered_at=datetime.now(UTC) if status == 'success' else None,
delivered_at=datetime.utcnow() if status == 'success' else None,
)
db.add(delivery)
await db.commit()
@@ -145,7 +145,7 @@ async def update_webhook_stats(
webhook.success_count += 1
else:
webhook.failure_count += 1
webhook.last_triggered_at = datetime.now(UTC)
webhook.last_triggered_at = datetime.utcnow()
await db.commit()
await db.refresh(webhook)
return webhook
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import func, select, update
@@ -77,7 +77,7 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
if welcome_text:
welcome_text.is_enabled = not welcome_text.is_enabled
welcome_text.updated_at = datetime.now(UTC)
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
@@ -182,7 +182,7 @@ async def update_welcome_text(
if is_active is not None:
welcome_text.is_active = is_active
welcome_text.updated_at = datetime.now(UTC)
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
+6 -6
View File
@@ -2,7 +2,7 @@
CRUD операции для колеса удачи (Fortune Wheel).
"""
from datetime import UTC, datetime
from datetime import datetime
from typing import Any
import structlog
@@ -65,7 +65,7 @@ async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig:
if hasattr(config, key) and value is not None:
setattr(config, key, value)
config.updated_at = datetime.now(UTC)
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
logger.info('🎡 Обновлена конфигурация колеса', kwargs=kwargs)
@@ -143,7 +143,7 @@ async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> Wheel
if hasattr(prize, key) and value is not None:
setattr(prize, key, value)
prize.updated_at = datetime.now(UTC)
prize.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(prize)
logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=kwargs)
@@ -204,7 +204,7 @@ async def create_wheel_spin(
prize_value_kopeks=prize_value_kopeks,
generated_promocode_id=generated_promocode_id,
is_applied=is_applied,
applied_at=datetime.now(UTC) if is_applied else None,
applied_at=datetime.utcnow() if is_applied else None,
)
db.add(spin)
await db.commit()
@@ -219,7 +219,7 @@ async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None:
spin = result.scalar_one_or_none()
if spin:
spin.is_applied = True
spin.applied_at = datetime.now(UTC)
spin.applied_at = datetime.utcnow()
await db.commit()
await db.refresh(spin)
return spin
@@ -227,7 +227,7 @@ async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None:
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
"""Получить количество спинов пользователя за сегодня."""
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(WheelSpin.id)).where(
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
import structlog
from sqlalchemy import and_, select, update
@@ -88,7 +88,7 @@ async def update_yookassa_payment_status(
captured_at: datetime | None = None,
payment_method_type: str | None = None,
) -> YooKassaPayment | None:
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.now(UTC)}
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.utcnow()}
if captured_at:
update_data['captured_at'] = captured_at
@@ -125,7 +125,7 @@ async def link_yookassa_payment_to_transaction(
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.now(UTC))
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
)
await db.commit()
+98 -124
View File
@@ -1,25 +1,24 @@
import asyncio
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager
from functools import wraps
from typing import ParamSpec, TypeVar
from typing import TypeVar
import structlog
from sqlalchemy import bindparam, event, text
from sqlalchemy import bindparam, event, inspect, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
from app.config import settings
from app.database.models import Base
logger = structlog.get_logger(__name__)
T = TypeVar('T')
P = ParamSpec('P')
R = TypeVar('R')
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
@@ -68,7 +67,7 @@ _pg_connect_args = {
engine = create_async_engine(
DATABASE_URL,
poolclass=poolclass,
echo='debug' if settings.DEBUG else False,
echo=settings.DEBUG,
future=True,
# Кеш скомпилированных запросов (правильное размещение)
query_cache_size=500,
@@ -104,7 +103,7 @@ def with_db_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
delay: float = DEFAULT_RETRY_DELAY,
backoff: float = 2.0,
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
) -> Callable:
"""
Декоратор для автоматического retry при сбоях подключения к БД.
@@ -114,10 +113,10 @@ def with_db_retry(
backoff: Множитель задержки для каждой следующей попытки
"""
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exception: Exception | None = None
async def wrapper(*args, **kwargs):
last_exception = None
current_delay = delay
for attempt in range(1, attempts + 1):
@@ -138,9 +137,9 @@ def with_db_retry(
else:
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
raise last_exception # type: ignore[misc]
raise last_exception
return wrapper # type: ignore[return-value]
return wrapper
return decorator
@@ -151,10 +150,7 @@ async def execute_with_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
):
"""Выполнение SQL с retry логикой."""
if attempts < 1:
raise ValueError(f'attempts must be >= 1, got {attempts}')
last_exception: Exception | None = None
last_exception = None
delay = DEFAULT_RETRY_DELAY
for attempt in range(1, attempts + 1):
@@ -167,7 +163,7 @@ async def execute_with_retry(
await asyncio.sleep(delay)
delay *= 2
raise last_exception # type: ignore[misc]
raise last_exception
# ============================================================================
@@ -205,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 (не содержит ://)')
logger.warning('Невалидный DATABASE_URL', url=url[:20])
return None
return url
@@ -238,10 +234,7 @@ class DatabaseManager:
expire_on_commit=False,
autoflush=False,
)
from sqlalchemy.engine import make_url
safe_url = make_url(replica_url).render_as_string(hide_password=True)
logger.info('Read replica настроена', replica_url=safe_url)
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
except Exception as e:
logger.error('Не удалось настроить read replica', e=e)
self.read_replica_engine = None
@@ -409,7 +402,88 @@ batch_ops = BatchOperations()
# ============================================================================
async def close_db() -> None:
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info('🚀 Создание таблиц базы данных...')
try:
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, checkfirst=True))
except (ProgrammingError, Exception) as e:
# Игнорируем ошибки дублирования индексов/таблиц - они уже существуют
# Это может произойти если таблицы были созданы вручную или через миграции
error_str = str(e).lower()
error_type = type(e).__name__.lower()
# Проверяем оригинальную ошибку для asyncpg
orig_error = getattr(e, 'orig', None)
if orig_error:
orig_type = type(orig_error).__name__.lower()
if 'duplicatetableerror' in orig_type or 'duplicatekeyerror' in orig_type:
logger.warning(
'⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. Продолжаем инициализацию...'
)
return
# Проверяем, является ли это ошибкой дублирования
is_duplicate_error = (
'already exists' in error_str
or 'duplicate' in error_str
or 'duplicatetableerror' in error_type
or 'duplicatekeyerror' in error_type
)
if is_duplicate_error:
logger.warning(
'⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. Продолжаем инициализацию...'
)
# Продолжаем выполнение, так как основные таблицы могут быть созданы
else:
# Для других ошибок пробрасываем исключение
logger.error('❌ Ошибка при создании таблиц', error=e)
raise
if not IS_SQLITE:
logger.info('Создание индексов для оптимизации...')
async with engine.begin() as conn:
indexes = [
('users', 'CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)'),
(
'subscriptions',
'CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)',
),
(
'subscriptions',
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
'payments',
'CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)',
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
'Пропускаем создание индекса : таблица отсутствует', 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', table_name=table_name, e=e)
logger.info('База данных успешно инициализирована')
health = await db_manager.health_check()
logger.info('Database health', health=health)
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info('Закрытие соединений с БД...')
@@ -421,106 +495,6 @@ async def close_db() -> None:
logger.info('Все подключения к базе данных закрыты')
# ============================================================================
# SEQUENCE SYNCHRONIZATION (after DB restores)
# ============================================================================
def _quote_ident(name: str) -> str:
"""Quote a PostgreSQL identifier to prevent SQL injection."""
return '"' + name.replace('"', '""') + '"'
async def sync_postgres_sequences() -> bool:
"""Ensure PostgreSQL sequences match the current max values after restores."""
if IS_SQLITE:
logger.debug('Пропускаем синхронизацию последовательностей: SQLite')
return True
try:
async with engine.begin() as conn:
result = await conn.execute(
text(
"""
SELECT
cols.table_schema,
cols.table_name,
cols.column_name,
pg_get_serial_sequence(
format('%I.%I', cols.table_schema, cols.table_name),
cols.column_name
) AS sequence_path
FROM information_schema.columns AS cols
WHERE cols.column_default LIKE 'nextval(%'
AND cols.table_schema NOT IN ('pg_catalog', 'information_schema')
"""
)
)
sequences = result.fetchall()
if not sequences:
logger.info('Не найдено последовательностей PostgreSQL для синхронизации')
return True
for table_schema, table_name, column_name, sequence_path in sequences:
if not sequence_path:
continue
q_col = _quote_ident(column_name)
q_schema = _quote_ident(table_schema)
q_table = _quote_ident(table_name)
max_result = await conn.execute(text(f'SELECT COALESCE(MAX({q_col}), 0) FROM {q_schema}.{q_table}'))
max_value = max_result.scalar() or 0
# pg_get_serial_sequence returns e.g. '"public"."users_id_seq"'.
# Split on '"."' to handle quoted identifiers that may contain dots.
if '"."' in sequence_path:
seq_schema, seq_name = sequence_path.split('"."', 1)
seq_schema = seq_schema.strip('"')
seq_name = seq_name.strip('"')
else:
parts = sequence_path.split('.')
if len(parts) == 2:
seq_schema, seq_name = parts
else:
seq_schema, seq_name = 'public', parts[-1]
q_seq_schema = _quote_ident(seq_schema)
q_seq_name = _quote_ident(seq_name)
current_result = await conn.execute(
text(f'SELECT last_value, is_called FROM {q_seq_schema}.{q_seq_name}')
)
current_row = current_result.fetchone()
if current_row:
current_last, is_called = current_row
current_next = current_last + 1 if is_called else current_last
if current_next > max_value:
continue
await conn.execute(
text(
"""
SELECT setval(:sequence_name, :new_value, TRUE)
"""
),
{'sequence_name': sequence_path, 'new_value': max_value},
)
logger.info(
'Последовательность синхронизирована',
sequence_path=sequence_path,
max_value=max_value,
next_id=max_value + 1,
)
return True
except Exception as error:
logger.error('Ошибка синхронизации последовательностей PostgreSQL', error=error)
return False
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
-71
View File
@@ -1,71 +0,0 @@
"""Programmatic Alembic migration runner for bot startup."""
from pathlib import Path
import structlog
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
logger = structlog.get_logger(__name__)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_ALEMBIC_INI = _PROJECT_ROOT / 'alembic.ini'
def _get_alembic_config() -> Config:
"""Build Alembic Config pointing at the project root."""
from app.config import settings
cfg = Config(str(_ALEMBIC_INI))
cfg.set_main_option('sqlalchemy.url', settings.get_database_url())
return cfg
async def _needs_auto_stamp() -> bool:
"""Check if DB has existing tables but no alembic_version (transition from universal_migration)."""
from app.database.database import engine
async with engine.connect() as conn:
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
if has_alembic:
return False
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
return has_users
_INITIAL_REVISION = '0001'
async def run_alembic_upgrade() -> None:
"""Run ``alembic upgrade head``, auto-stamping existing databases first."""
import asyncio
if await _needs_auto_stamp():
logger.warning(
'Обнаружена существующая БД без alembic_version — автоматический stamp 0001 (переход с universal_migration)'
)
await _stamp_alembic_revision(_INITIAL_REVISION)
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
# run_in_executor offloads to a thread where env.py can safely
# call asyncio.run() to create its own event loop.
await loop.run_in_executor(None, command.upgrade, cfg, 'head')
logger.info('Alembic миграции применены')
async def stamp_alembic_head() -> None:
"""Stamp the DB as being at head without running migrations (for existing DBs)."""
await _stamp_alembic_revision('head')
async def _stamp_alembic_revision(revision: str) -> None:
"""Stamp the DB at a specific revision without running migrations."""
import asyncio
cfg = _get_alembic_config()
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, command.stamp, cfg, revision)
logger.info('Alembic: база отмечена как актуальная', revision=revision)
+191 -243
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import base64
import json
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any
import aiohttp
@@ -33,12 +33,12 @@ class WataPublicKeyProvider:
async def get_public_key(self) -> str | None:
"""Returns a cached public key or fetches a new one from WATA."""
now = datetime.now(UTC)
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
async with self._lock:
now = datetime.now(UTC)
now = datetime.utcnow()
if self._cached_key and self._expires_at and now < self._expires_at:
return self._cached_key
@@ -46,7 +46,7 @@ class WataPublicKeyProvider:
if key:
self._cached_key = key
if self._cache_seconds > 0:
self._expires_at = datetime.now(UTC) + timedelta(seconds=self._cache_seconds)
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
else:
self._expires_at = None
logger.debug('Получен и закеширован публичный ключ WATA')
+3 -3
View File
@@ -3,7 +3,7 @@ import io
import math
import time
from collections.abc import Iterable
from datetime import UTC, datetime
from datetime import datetime
from aiogram import Dispatcher, F, types
from aiogram.filters import BaseFilter, StateFilter
@@ -742,7 +742,7 @@ async def export_settings(
keys = sorted(set(keys))
lines = [
'# RemnaWave bot configuration export',
f'# Generated at {datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")}',
f'# Generated at {datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}',
]
for setting_key in keys:
@@ -753,7 +753,7 @@ async def export_settings(
lines.append(f'{setting_key}={raw_value}')
content = '\n'.join(lines)
filename = f'bot-settings-{datetime.now(UTC).strftime("%Y%m%d-%H%M%S")}.env'
filename = f'bot-settings-{datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.env'
file = types.BufferedInputFile(content.encode('utf-8'), filename=filename)
await callback.message.answer_document(
+3 -3
View File
@@ -399,7 +399,7 @@ async def delete_contest(
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
now_utc = datetime.now(UTC)
now_utc = datetime.utcnow()
if contest.is_active or contest.end_at > now_utc:
await callback.answer(
texts.t('ADMIN_CONTEST_DELETE_RESTRICT', 'Удалять можно только завершённые конкурсы.'),
@@ -628,8 +628,8 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d
await message.answer(texts.t('ADMIN_CONTEST_INVALID_DATE', 'Не удалось распознать дату.'))
return
start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC)
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC)
start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC).replace(tzinfo=None)
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC).replace(tzinfo=None)
contest_type = data.get('contest_type') or 'referral_paid'
+10 -10
View File
@@ -1,5 +1,5 @@
import json
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -171,7 +171,7 @@ async def start_round_now(
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -182,8 +182,8 @@ async def start_round_now(
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@@ -214,7 +214,7 @@ async def manual_start_round(
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -227,8 +227,8 @@ async def manual_start_round(
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Тестовый раунд запущен'), show_alert=True)
await show_daily_contest(callback, db_user, db)
@@ -431,7 +431,7 @@ async def start_all_contests(
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.now(UTC)
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
await create_round(
db,
@@ -442,8 +442,8 @@ async def start_all_contests(
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now,
ends,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
started_count += 1
+36 -64
View File
@@ -1,6 +1,6 @@
import asyncio
import html
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -134,7 +134,6 @@ async def _persist_broadcast_result(
sent_count: int,
failed_count: int,
status: str,
blocked_count: int = 0,
) -> None:
"""
Сохраняет результаты рассылки в НОВОЙ сессии.
@@ -148,9 +147,8 @@ async def _persist_broadcast_result(
sent_count: Количество успешно отправленных сообщений
failed_count: Количество неудачных отправок
status: Финальный статус рассылки ('completed', 'partial', 'failed')
blocked_count: Количество пользователей, заблокировавших бота
"""
completed_at = datetime.now(UTC)
completed_at = datetime.utcnow()
max_retries = 3
retry_delay = 1.0
@@ -166,17 +164,15 @@ async def _persist_broadcast_result(
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.blocked_count = blocked_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
await session.commit()
logger.info(
'Результаты рассылки сохранены (id sent failed blocked status=)',
'Результаты рассылки сохранены (id sent failed status=)',
broadcast_id=broadcast_id,
sent_count=sent_count,
failed_count=failed_count,
blocked_count=blocked_count,
status=status,
)
return
@@ -313,7 +309,7 @@ async def toggle_pinned_message_position(
return
pinned_message.send_before_menu = not pinned_message.send_before_menu
pinned_message.updated_at = datetime.now(UTC)
pinned_message.updated_at = datetime.utcnow()
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@@ -333,7 +329,7 @@ async def toggle_pinned_message_start_mode(
return
pinned_message.send_on_every_start = not pinned_message.send_on_every_start
pinned_message.updated_at = datetime.now(UTC)
pinned_message.updated_at = datetime.utcnow()
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@@ -1219,8 +1215,8 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
flood_wait_until: float = 0.0
async def send_single_broadcast(telegram_id: int) -> str:
"""Отправляет одно сообщение. Возвращает 'sent', 'blocked' или 'failed'."""
async def send_single_broadcast(telegram_id: int) -> bool:
"""Отправляет одно сообщение. Возвращает True при успехе."""
nonlocal flood_wait_until
for attempt in range(_MAX_SEND_RETRIES):
@@ -1264,7 +1260,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
return 'sent'
return True
except TelegramRetryAfter as e:
# Глобальная пауза — тормозим все корутины
@@ -1280,14 +1276,11 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return 'blocked'
return False
except TelegramBadRequest as e:
err = str(e).lower()
if 'bot was blocked' in err or 'user is deactivated' in err or 'chat not found' in err:
return 'blocked'
logger.debug('BadRequest при рассылке пользователю', telegram_id=telegram_id, e=e)
return 'failed'
return False
except Exception as e:
logger.error(
@@ -1300,7 +1293,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
if attempt < _MAX_SEND_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return 'failed'
return False
# =========================================================================
# Прогресс-бар в реальном времени (как в сканере заблокированных)
@@ -1315,29 +1308,26 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
current_failed: int,
total: int,
phase: str = 'sending',
current_blocked: int = 0,
) -> str:
processed = current_sent + current_failed + current_blocked
processed = current_sent + current_failed
percent = round(processed / total * 100, 1) if total > 0 else 0
bar_length = 20
filled = int(bar_length * processed / total) if total > 0 else 0
bar = '' * filled + '' * (bar_length - filled)
if phase == 'sending':
blocked_line = f'• Заблокировали бота: {current_blocked}\n' if current_blocked else ''
return (
f'📨 <b>Рассылка в процессе...</b>\n\n'
f'[{bar}] {percent}%\n\n'
f'📊 <b>Прогресс:</b>\n'
f'• Отправлено: {current_sent}\n'
f'{blocked_line}'
f'• Ошибок: {current_failed}\n'
f'• Обработано: {processed}/{total}\n\n'
f'⏳ Не закрывайте диалог — рассылка продолжается...'
)
return ''
async def _update_progress_message(current_sent: int, current_failed: int, current_blocked: int = 0) -> None:
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
"""Безопасно обновляет сообщение с прогрессом."""
nonlocal last_progress_update, progress_message
now = asyncio.get_event_loop().time()
@@ -1345,7 +1335,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
return
last_progress_update = now
text = _build_progress_text(current_sent, current_failed, total_recipients, current_blocked=current_blocked)
text = _build_progress_text(current_sent, current_failed, total_recipients)
try:
await progress_message.edit_text(text, parse_mode='HTML')
except TelegramRetryAfter as e:
@@ -1367,9 +1357,6 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Первое обновление прогресса
await _update_progress_message(0, 0)
blocked_count = 0
blocked_telegram_ids: list[int] = []
# =========================================================================
# Основной цикл рассылки — батчами по _BATCH_SIZE
# =========================================================================
@@ -1382,13 +1369,10 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
return_exceptions=True,
)
for idx, result in enumerate(results):
if isinstance(result, str):
if result == 'sent':
for result in results:
if isinstance(result, bool):
if result:
sent_count += 1
elif result == 'blocked':
blocked_count += 1
blocked_telegram_ids.append(batch[idx])
else:
failed_count += 1
elif isinstance(result, Exception):
@@ -1397,28 +1381,17 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
await _update_progress_message(sent_count, failed_count, blocked_count)
await _update_progress_message(sent_count, failed_count)
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
# Фоновая очистка заблокировавших бота пользователей
if blocked_telegram_ids:
from app.services.broadcast_service import _background_tasks, cleanup_blocked_broadcast_users
task = asyncio.create_task(
cleanup_blocked_broadcast_users(blocked_telegram_ids),
name=f'broadcast-{broadcast_id}-blocked-cleanup',
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
# Учитываем пропущенных email-only пользователей
skipped_email_users = total_users_count - total_recipients
if skipped_email_users > 0:
logger.info('Пропущено email-only пользователей при рассылке', skipped_email_users=skipped_email_users)
status = 'completed' if failed_count == 0 and blocked_count == 0 else 'partial'
status = 'completed' if failed_count == 0 else 'partial'
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
await _persist_broadcast_result(
@@ -1426,18 +1399,15 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
sent_count=sent_count,
failed_count=failed_count,
status=status,
blocked_count=blocked_count,
)
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
blocked_line = f'• Заблокировали бота: {blocked_count}\n' if blocked_count else ''
result_text = (
f'✅ <b>Рассылка завершена!</b>\n\n'
f'📊 <b>Результат:</b>\n'
f'• Отправлено: {sent_count}\n'
f'{blocked_line}'
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
@@ -1479,6 +1449,8 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
async def get_target_users_count(db: AsyncSession, target: str) -> int:
"""Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память."""
from datetime import datetime, timedelta
from sqlalchemy import distinct, func as sql_func
base_filter = User.status == UserStatus.ACTIVE.value
@@ -1531,7 +1503,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expiring':
# Истекающие в ближайшие 3 дня
now = datetime.now(UTC)
now = datetime.utcnow()
expiry_threshold = now + timedelta(days=3)
query = (
select(sql_func.count(distinct(User.id)))
@@ -1548,7 +1520,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expiring_subscribers':
# Истекающие в ближайшие 7 дней
now = datetime.now(UTC)
now = datetime.utcnow()
expiry_threshold = now + timedelta(days=7)
query = (
select(sql_func.count(distinct(User.id)))
@@ -1565,7 +1537,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired':
# Истекшие подписки
now = datetime.now(UTC)
now = datetime.utcnow()
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
@@ -1584,7 +1556,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
now = datetime.utcnow()
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
query = (
select(sql_func.count(distinct(User.id)))
@@ -1662,7 +1634,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
# Custom filters — быстрый COUNT вместо загрузки всех пользователей
if target.startswith('custom_'):
now = datetime.now(UTC)
now = datetime.utcnow()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
criteria = target[len('custom_') :]
@@ -1732,7 +1704,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [sub.user for sub in expiring_subs if sub.user]
if target == 'expired':
now = datetime.now(UTC)
now = datetime.utcnow()
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -1783,7 +1755,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
return [sub.user for sub in expiring_subs if sub.user]
if target == 'expired_subscribers':
now = datetime.now(UTC)
now = datetime.utcnow()
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -1810,7 +1782,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'trial_ending':
now = datetime.now(UTC)
now = datetime.utcnow()
in_3_days = now + timedelta(days=3)
return [
user
@@ -1822,7 +1794,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'trial_expired':
now = datetime.now(UTC)
now = datetime.utcnow()
return [
user
for user in users
@@ -1832,7 +1804,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
if target == 'autopay_failed':
from app.database.models import SubscriptionEvent
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.utcnow() - timedelta(days=7)
stmt = (
select(SubscriptionEvent.user_id)
.where(
@@ -1854,15 +1826,15 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
]
if target == 'inactive_30d':
threshold = datetime.now(UTC) - timedelta(days=30)
threshold = datetime.utcnow() - timedelta(days=30)
return [user for user in users if user.last_activity and user.last_activity < threshold]
if target == 'inactive_60d':
threshold = datetime.now(UTC) - timedelta(days=60)
threshold = datetime.utcnow() - timedelta(days=60)
return [user for user in users if user.last_activity and user.last_activity < threshold]
if target == 'inactive_90d':
threshold = datetime.now(UTC) - timedelta(days=90)
threshold = datetime.utcnow() - timedelta(days=90)
return [user for user in users if user.last_activity and user.last_activity < threshold]
# Фильтр по тарифу
@@ -1883,7 +1855,7 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
now = datetime.now(UTC)
now = datetime.utcnow()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
@@ -1912,7 +1884,7 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
async def get_users_statistics(db: AsyncSession) -> dict:
now = datetime.now(UTC)
now = datetime.utcnow()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
+12 -13
View File
@@ -1,5 +1,5 @@
import asyncio
from datetime import UTC, date, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import F, Router
@@ -139,7 +139,7 @@ def _build_notification_settings_view(language: str):
def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now(UTC)
now = datetime.now()
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
@@ -694,7 +694,7 @@ async def force_check_callback(callback: CallbackQuery):
Истекающих подписок: {results['expiring']}
Готовых к автооплате: {results['autopay_ready']}
🕐 <b>Время проверки:</b> {datetime.now(UTC).strftime('%H:%M:%S')}
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
Нажмите "Назад" для возврата в меню мониторинга.
"""
@@ -748,7 +748,7 @@ async def traffic_check_callback(callback: CallbackQuery):
Порог дельты: {threshold_gb} ГБ
Возраст snapshot: {snapshot_age:.1f} мин
🕐 <b>Время проверки:</b> {datetime.now(UTC).strftime('%H:%M:%S')}
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
"""
if violations:
@@ -862,7 +862,7 @@ async def test_notifications_callback(callback: CallbackQuery):
📊 <b>Статус системы:</b>
Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
Время теста: {datetime.now(UTC).strftime('%H:%M:%S %d.%m.%Y')}
Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
Если вы получили это сообщение, система уведомлений работает корректно!
"""
@@ -887,7 +887,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
@@ -1013,7 +1013,7 @@ async def nalogo_force_process_callback(callback: CallbackQuery):
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now(UTC) - timedelta(days=7)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
@@ -1235,6 +1235,8 @@ async def receipts_missing_callback(callback: CallbackQuery):
async def receipts_link_old_callback(callback: CallbackQuery):
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
try:
from datetime import date, timedelta
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import and_, select
@@ -1243,7 +1245,7 @@ async def receipts_link_old_callback(callback: CallbackQuery):
await callback.answer('🔄 Загружаю чеки из NaloGO...', show_alert=False)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0, tzinfo=UTC)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
async with AsyncSessionLocal() as db:
# Получаем старые транзакции без чеков
@@ -1309,12 +1311,9 @@ async def receipts_link_old_callback(callback: CallbackQuery):
try:
from dateutil.parser import isoparse
parsed_time = isoparse(operation_time)
t.receipt_created_at = (
parsed_time if parsed_time.tzinfo else parsed_time.replace(tzinfo=UTC)
)
t.receipt_created_at = isoparse(operation_time)
except Exception:
t.receipt_created_at = datetime.now(UTC)
t.receipt_created_at = datetime.utcnow()
linked += 1
if linked > 0:
+3 -2
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import html
import math
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
@@ -823,7 +822,9 @@ async def export_payments(
file_bytes = json_content.encode('utf-8')
# Отправляем файл
filename = f'payments_export_{datetime.now(UTC).strftime("%Y%m%d_%H%M%S")}.json'
from datetime import datetime
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
await callback.message.answer_document(
document=BufferedInputFile(file_bytes, filename=filename),
+4 -4
View File
@@ -4,7 +4,7 @@ import asyncio
import html
import re
from collections.abc import Sequence
from datetime import UTC, datetime
from datetime import datetime
import structlog
from aiogram import Dispatcher, F, types
@@ -260,7 +260,7 @@ def _format_offer_remaining(offer, texts) -> str:
if not offer.expires_at:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока')
remaining_seconds = int((offer.expires_at - datetime.now(UTC)).total_seconds())
remaining_seconds = int((offer.expires_at - datetime.utcnow()).total_seconds())
if remaining_seconds <= 0:
return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED', 'истекло')
@@ -1512,7 +1512,7 @@ async def show_selected_user_details(
lines.append('')
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION', '💳 Подписка отсутствует'))
now = datetime.now(UTC)
now = datetime.utcnow()
percent = 0
try:
percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0)
@@ -1772,7 +1772,7 @@ async def show_selected_user_details(
)
if subscription:
now = datetime.now(UTC)
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
+4 -4
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -722,7 +722,7 @@ async def process_promocode_expiry(message: types.Message, db_user: User, state:
valid_until = None
if expiry_days > 0:
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
'balance': PromoCodeType.BALANCE,
@@ -803,7 +803,7 @@ async def process_discount_hours(message: types.Message, db_user: User, state: F
valid_until = None
if expiry_days > 0:
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
# Создаем DISCOUNT промокод
# balance_bonus_kopeks = процент скидки (НЕ копейки!)
@@ -877,7 +877,7 @@ async def handle_edit_expiry(message: types.Message, db_user: User, state: FSMCo
valid_until = None
if expiry_days > 0:
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
await update_promocode(db, promo, valid_until=valid_until)
+17 -17
View File
@@ -1,5 +1,5 @@
import datetime
import json
from datetime import UTC, datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -33,7 +33,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
if stats.get('active_referrers', 0) > 0:
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
current_time = datetime.now(UTC).strftime('%H:%M:%S')
current_time = datetime.datetime.now().strftime('%H:%M:%S')
text = f"""
🤝 <b>Реферальная статистика</b>
@@ -114,7 +114,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
except Exception as e:
logger.error('Ошибка в show_referral_statistics', error=e, exc_info=True)
current_time = datetime.now(UTC).strftime('%H:%M:%S')
current_time = datetime.datetime.now().strftime('%H:%M:%S')
text = f"""
🤝 <b>Реферальная статистика</b>
@@ -471,9 +471,7 @@ async def reject_withdrawal_request(callback: types.CallbackQuery, db_user: User
await callback.answer('Заявка не найдена', show_alert=True)
return
success, _error = await referral_withdrawal_service.reject_request(
db, request_id, db_user.id, 'Отклонено администратором'
)
success = await referral_withdrawal_service.reject_request(db, request_id, db_user.id, 'Отклонено администратором')
if success:
# Уведомляем пользователя (только если есть telegram_id)
@@ -514,7 +512,7 @@ async def complete_withdrawal_request(callback: types.CallbackQuery, db_user: Us
await callback.answer('Заявка не найдена', show_alert=True)
return
success, _error = await referral_withdrawal_service.complete_request(db, request_id, db_user.id, 'Перевод выполнен')
success = await referral_withdrawal_service.complete_request(db, request_id, db_user.id, 'Перевод выполнен')
if success:
# Уведомляем пользователя (только если есть telegram_id)
@@ -656,27 +654,27 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
)
def _get_period_dates(period: str) -> tuple[datetime, datetime]:
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
"""Возвращает начальную и конечную даты для заданного периода."""
now = datetime.now(UTC)
now = datetime.datetime.now()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if period == 'today':
start_date = today
end_date = today + timedelta(days=1)
end_date = today + datetime.timedelta(days=1)
elif period == 'yesterday':
start_date = today - timedelta(days=1)
start_date = today - datetime.timedelta(days=1)
end_date = today
elif period == 'week':
start_date = today - timedelta(days=7)
end_date = today + timedelta(days=1)
start_date = today - datetime.timedelta(days=7)
end_date = today + datetime.timedelta(days=1)
elif period == 'month':
start_date = today - timedelta(days=30)
end_date = today + timedelta(days=1)
start_date = today - datetime.timedelta(days=30)
end_date = today + datetime.timedelta(days=1)
else:
# По умолчанию — сегодня
start_date = today
end_date = today + timedelta(days=1)
end_date = today + datetime.timedelta(days=1)
return start_date, end_date
@@ -1155,7 +1153,9 @@ async def sync_referrals_with_contest(
await callback.answer('🏆 Синхронизирую с конкурсами...')
try:
now_utc = datetime.now(UTC)
from datetime import datetime
now_utc = datetime.utcnow()
# Получаем активные конкурсы
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
+6 -4
View File
@@ -1,5 +1,5 @@
import math
from datetime import UTC, datetime, timedelta
from datetime import datetime
from typing import Any
import structlog
@@ -1041,7 +1041,7 @@ async def show_system_stats(callback: types.CallbackQuery, db_user: User, db: As
"""
text += f"""
🕒 <b>Обновлено:</b> {format_datetime(stats.get('last_updated', datetime.now(UTC)))}
🕒 <b>Обновлено:</b> {format_datetime(stats.get('last_updated', datetime.now()))}
"""
keyboard = [
@@ -1167,7 +1167,7 @@ async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: A
for i, (node_name, total_bytes) in enumerate(sorted_nodes[:5], 1):
text += f'{i}. {node_name}: {format_bytes(total_bytes)}\n'
text += f'\n🕒 <b>Обновлено:</b> {format_datetime(datetime.now(UTC))}'
text += f'\n🕒 <b>Обновлено:</b> {format_datetime(datetime.now())}'
keyboard = [
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_traffic')],
@@ -1315,7 +1315,9 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
return
try:
end_date = datetime.now(UTC)
from datetime import datetime, timedelta
end_date = datetime.now()
start_date = end_date - timedelta(days=7)
node_usage = await remnawave_service.get_node_user_usage_by_range(node_uuid, start_date, end_date)
+10 -10
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import Dispatcher, F, types
@@ -40,7 +40,7 @@ async def show_users_statistics(callback: types.CallbackQuery, db_user: User, db
total_users = stats['total_users']
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
current_time = format_datetime(datetime.now(UTC))
current_time = format_datetime(datetime.utcnow())
text = f"""
👥 <b>Статистика пользователей</b>
@@ -89,7 +89,7 @@ async def show_subscriptions_statistics(callback: types.CallbackQuery, db_user:
total_subs = stats['total_subscriptions']
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
current_time = format_datetime(datetime.now(UTC))
current_time = format_datetime(datetime.utcnow())
text = f"""
📱 <b>Статистика подписок</b>
@@ -133,12 +133,12 @@ async def show_subscriptions_statistics(callback: types.CallbackQuery, db_user:
@admin_required
@error_handler
async def show_revenue_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
now = datetime.now(UTC)
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(db)
current_time = format_datetime(datetime.now(UTC))
current_time = format_datetime(datetime.utcnow())
text = f"""
💰 <b>Статистика доходов</b>
@@ -189,7 +189,7 @@ async def show_revenue_statistics(callback: types.CallbackQuery, db_user: User,
@error_handler
async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
stats = await get_referral_statistics(db)
current_time = format_datetime(datetime.now(UTC))
current_time = format_datetime(datetime.utcnow())
avg_per_referrer = 0
if stats['active_referrers'] > 0:
@@ -250,10 +250,10 @@ async def show_summary_statistics(callback: types.CallbackQuery, db_user: User,
user_stats = await user_service.get_user_statistics(db)
sub_stats = await get_subscriptions_statistics(db)
now = datetime.now(UTC)
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
revenue_stats = await get_transactions_statistics(db, month_start, now)
current_time = format_datetime(datetime.now(UTC))
current_time = format_datetime(datetime.utcnow())
conversion_rate = 0
if user_stats['total_users'] > 0:
@@ -317,10 +317,10 @@ async def show_revenue_by_period(callback: types.CallbackQuery, db_user: User, d
revenue_data = await get_revenue_by_period(db, days)
if period == 'yesterday':
yesterday = datetime.now(UTC).date() - timedelta(days=1)
yesterday = datetime.utcnow().date() - timedelta(days=1)
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
elif period == 'today':
today = datetime.now(UTC).date()
today = datetime.utcnow().date()
revenue_data = [r for r in revenue_data if r['date'] == today]
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
+3 -3
View File
@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import datetime
from html import escape
from pathlib import Path
@@ -47,7 +47,7 @@ def _build_logs_message(log_path: Path) -> str:
total_length = len(content)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime, tz=UTC)
updated_at = datetime.fromtimestamp(stats.st_mtime)
if not content:
preview_text = 'Лог-файл пуст.'
@@ -128,7 +128,7 @@ async def download_system_logs(
document = FSInputFile(log_path)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime, tz=UTC).strftime('%d.%m.%Y %H:%M:%S')
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime('%d.%m.%Y %H:%M:%S')
caption = (
f'🧾 Лог-файл <code>{log_path.name}</code>\n📁 Путь: <code>{log_path}</code>\n🕒 Обновлен: {updated_at}'
)
+3 -3
View File
@@ -1,6 +1,6 @@
import html
import time
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
import structlog
from aiogram import Bot, Dispatcher, F, types
@@ -449,7 +449,7 @@ async def handle_admin_ticket_reply(message: types.Message, state: FSMContext, d
except ValueError:
await message.answer('❌ Введите целое число минут')
return
until = datetime.now(UTC) + timedelta(minutes=minutes)
until = datetime.utcnow() + timedelta(minutes=minutes)
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
if ok:
await message.answer(f'✅ Пользователь заблокирован на {minutes} минут')
@@ -769,7 +769,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
await state.clear()
return
until = datetime.now(UTC) + timedelta(minutes=minutes)
until = datetime.utcnow() + timedelta(minutes=minutes)
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
if not ok:
await message.answer('❌ Ошибка блокировки')

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