Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 799c83dd84 | |||
| 4cc18cbc9a | |||
| 4645be53cb | |||
| 79ea398d1d | |||
| 30b1402b54 | |||
| 15d848c1ca | |||
| c9877a3cbe | |||
| 68499ee043 | |||
| bdb61613de | |||
| 59383bdbd8 | |||
| 5d4a94b8ce | |||
| 0c07812ecc | |||
| eb9dba3f47 | |||
| 6c4e035146 | |||
| e78b1040a5 | |||
| b6c7f91a7c | |||
| e998059d81 | |||
| 764e063bfe | |||
| 784616b349 | |||
| b4b10c998c | |||
| 366df18c54 | |||
| 7883efc3d6 | |||
| 6881d97bbb | |||
| 90278f1f5f | |||
| df5b1a072d | |||
| 10e231e52e | |||
| 7c20fde4e8 | |||
| fcf3a2c806 | |||
| 88997492c3 | |||
| 327d4f4d15 | |||
| cf7cc5a84e | |||
| 28f524b762 | |||
| c4dc43e054 | |||
| 767e965028 | |||
| d39063b22f | |||
| ea5d932476 | |||
| acc1323a54 | |||
| 58bfaeaddb | |||
| df5415f30b | |||
| 330d670f3f | |||
| 41cb122a2f | |||
| 1b3e6f2f11 | |||
| 6fa49485d9 | |||
| 71aa023133 | |||
| e567c02658 | |||
| f393dc0840 | |||
| 5dc4b0ec15 | |||
| e68760cc66 | |||
| d9552799c1 | |||
| c75ec0b22a | |||
| 27309f53d9 | |||
| 4193f717ee | |||
| d297985b0b | |||
| 6dcf3a9f0d | |||
| 094609005a | |||
| eb18994b7d | |||
| ff21b27b98 | |||
| 4247981c98 | |||
| c30972f6a7 | |||
| 7628fb9f6e | |||
| 4c48eadebc | |||
| 6ea3860a2f | |||
| 1b8ef69a1b | |||
| 9d710050ad | |||
| 491a7e1c42 | |||
| 7eb8d4e153 | |||
| f63720467a | |||
| 516be6e600 | |||
| 0807a9ff19 | |||
| a93a32f3a7 | |||
| 68de66f526 | |||
| 15aba2b3db |
+6
-1
@@ -371,7 +371,8 @@ 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
|
||||
@@ -384,6 +385,8 @@ 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 = основной чат)
|
||||
@@ -845,6 +848,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
|
||||
LOG_COLORS=true
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.12.1"
|
||||
".": "3.16.1"
|
||||
}
|
||||
|
||||
+126
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
|
||||
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
|
||||
|
||||
## [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
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.12.1" # x-release-please-version
|
||||
ARG VERSION="v3.16.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ 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
@@ -2,7 +2,7 @@
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() + timedelta(minutes=minutes)
|
||||
return datetime.now(UTC) + 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.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + 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.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + 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.utcnow() > expires_at
|
||||
return datetime.now(UTC) > expires_at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() + timedelta(minutes=expire_minutes)
|
||||
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
# Добавляем 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.utcnow() + timedelta(days=expire_days)
|
||||
expires = datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
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.utcnow() + timedelta(days=expire_days)
|
||||
return datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - 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.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -23,6 +24,7 @@ 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
|
||||
@@ -31,6 +33,7 @@ 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
|
||||
@@ -43,6 +46,7 @@ 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
|
||||
@@ -54,6 +58,8 @@ 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)
|
||||
@@ -83,6 +89,8 @@ 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -118,9 +118,10 @@ 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) / broadcast.total_count * 100, 1)
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
@@ -133,6 +134,7 @@ 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,
|
||||
@@ -724,7 +726,7 @@ async def stop_broadcast(
|
||||
broadcast.status = 'cancelling'
|
||||
else:
|
||||
broadcast.status = 'cancelled'
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
broadcast.completed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""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
|
||||
@@ -20,7 +22,9 @@ 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,
|
||||
@@ -28,6 +32,7 @@ from app.database.models import (
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.campaigns import (
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
@@ -57,6 +62,22 @@ 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),
|
||||
@@ -132,6 +153,26 @@ 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,
|
||||
@@ -158,6 +199,8 @@ 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,
|
||||
)
|
||||
)
|
||||
@@ -201,10 +244,13 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +294,7 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -288,19 +335,22 @@ async def get_campaign_registrations(
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
# Check if user has subscription
|
||||
# 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)
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.limit(1)
|
||||
.distinct()
|
||||
)
|
||||
has_sub = sub_result.scalar_one_or_none() is not None
|
||||
active_sub_user_ids = set(sub_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
items.append(
|
||||
CampaignRegistrationItem(
|
||||
id=reg.id,
|
||||
@@ -315,7 +365,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=has_sub,
|
||||
has_subscription=user.id in active_sub_user_ids,
|
||||
has_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
)
|
||||
@@ -358,6 +408,15 @@ 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,
|
||||
@@ -372,6 +431,7 @@ 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
|
||||
@@ -443,8 +503,26 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
"""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}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
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.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
|
||||
@@ -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).replace(tzinfo=None)
|
||||
return value.astimezone(UTC)
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -109,7 +109,10 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -338,7 +341,7 @@ async def get_node_usage(
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
end_dt = end or datetime.now(UTC)
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
|
||||
@@ -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(Transaction.amount_kopeks), 0))
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
@@ -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.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -522,9 +522,9 @@ async def update_ticket_status(
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
ticket.closed_at = datetime.now(UTC)
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
@@ -581,7 +581,7 @@ async def update_ticket_priority(
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
@@ -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(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
if datetime.now(UTC) - _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()
|
||||
_cabinet_last_check = datetime.now(UTC)
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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_naive_utc
|
||||
from app.utils.timezone import panel_datetime_to_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.utcnow()
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_remaining = max(0, delta.days)
|
||||
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.utcnow()
|
||||
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
and subscription.end_date > datetime.now(UTC)
|
||||
)
|
||||
panel_status = PanelUserStatus.ACTIVE if is_active else PanelUserStatus.DISABLED
|
||||
|
||||
expire_at = subscription.end_date
|
||||
if expire_at and expire_at <= datetime.utcnow():
|
||||
expire_at = datetime.utcnow() + timedelta(minutes=1)
|
||||
if expire_at and expire_at <= datetime.now(UTC):
|
||||
expire_at = datetime.now(UTC) + 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.utcnow() + timedelta(days=30)),
|
||||
'expire_at': expire_at or (datetime.now(UTC) + 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.utcnow()
|
||||
user.last_remnawave_sync = datetime.now(UTC)
|
||||
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.utcnow(),
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
),
|
||||
Integer,
|
||||
)
|
||||
@@ -431,7 +431,7 @@ async def get_users_stats(
|
||||
func.cast(
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= datetime.utcnow(),
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
),
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
end_date = datetime.now(UTC)
|
||||
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.utcnow():
|
||||
if request.end_date > datetime.now(UTC):
|
||||
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.utcnow()
|
||||
subscription.end_date = datetime.now(UTC)
|
||||
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.utcnow():
|
||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||
# Extend by 30 days if expired
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=30)
|
||||
subscription.end_date = datetime.now(UTC) + 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
@@ -2179,13 +2179,8 @@ 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:
|
||||
# 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)
|
||||
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)
|
||||
|
||||
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
|
||||
# Allow for timezone offset (3 hours = MSK) and small sync delays
|
||||
@@ -2319,7 +2314,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_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
|
||||
expire_at=panel_datetime_to_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,
|
||||
@@ -2338,13 +2333,10 @@ async def sync_user_from_panel(
|
||||
|
||||
# Update end date (normalize timezone)
|
||||
if 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)
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
|
||||
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:
|
||||
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:
|
||||
changes['end_date'] = {
|
||||
'old': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'new': panel_expire_utc.isoformat(),
|
||||
@@ -2353,7 +2345,7 @@ async def sync_user_from_panel(
|
||||
|
||||
# Update status
|
||||
panel_status_str = panel_user.status.value if panel_user.status else 'DISABLED'
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
# 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:
|
||||
@@ -2410,9 +2402,8 @@ 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 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)
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
days_remaining = max(1, (panel_expire_utc - datetime.now(UTC)).days)
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
db=db,
|
||||
@@ -2427,8 +2418,8 @@ async def sync_user_from_panel(
|
||||
changes['subscription_created'] = True
|
||||
|
||||
# Update last sync time
|
||||
user.last_remnawave_sync = datetime.utcnow()
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_remnawave_sync = datetime.now(UTC)
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -2502,14 +2493,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.utcnow()
|
||||
and sub.end_date > datetime.now(UTC)
|
||||
)
|
||||
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.utcnow():
|
||||
expire_at = datetime.utcnow() + timedelta(minutes=1)
|
||||
if expire_at and expire_at <= datetime.now(UTC):
|
||||
expire_at = datetime.now(UTC) + timedelta(minutes=1)
|
||||
|
||||
username = settings.format_remnawave_username(
|
||||
full_name=user.full_name,
|
||||
@@ -2595,7 +2586,7 @@ async def sync_user_to_panel(
|
||||
# Create new user in panel
|
||||
create_kwargs = {
|
||||
'username': username,
|
||||
'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)),
|
||||
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
@@ -2620,8 +2611,8 @@ async def sync_user_to_panel(
|
||||
action = 'created'
|
||||
|
||||
# Update last sync time
|
||||
user.last_remnawave_sync = datetime.utcnow()
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_remnawave_sync = datetime.now(UTC)
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""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}
|
||||
+102
-20
@@ -11,6 +11,10 @@ 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,
|
||||
@@ -23,9 +27,10 @@ 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_naive_utc
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -49,6 +54,7 @@ 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,
|
||||
@@ -118,12 +124,6 @@ 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,9 +133,71 @@ async def _store_refresh_token(
|
||||
db.add(token_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Handle race condition if token was inserted between check and insert
|
||||
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
|
||||
|
||||
|
||||
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
|
||||
@@ -175,7 +237,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_naive_utc(panel_user.expire_at)
|
||||
expire_at = panel_datetime_to_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
|
||||
|
||||
@@ -186,7 +248,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).replace(tzinfo=None)
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -313,7 +375,7 @@ async def auth_telegram(
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
@@ -321,6 +383,11 @@ 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
|
||||
|
||||
|
||||
@@ -335,7 +402,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()
|
||||
widget_data = request.model_dump(exclude={'campaign_slug'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
@@ -374,12 +441,17 @@ async def auth_telegram_widget(
|
||||
if request.last_name != user.last_name:
|
||||
user.last_name = request.last_name
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
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
|
||||
|
||||
|
||||
@@ -546,7 +618,7 @@ async def register_email_standalone(
|
||||
# Для тестового email - автоматически верифицировать
|
||||
if is_test_email:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
|
||||
else:
|
||||
@@ -633,10 +705,10 @@ async def verify_email(
|
||||
|
||||
# Mark email as verified
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -647,6 +719,11 @@ 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
|
||||
|
||||
|
||||
@@ -750,7 +827,7 @@ async def login_email(
|
||||
language='ru',
|
||||
)
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -783,12 +860,17 @@ async def login_email(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
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
|
||||
|
||||
|
||||
@@ -871,7 +953,7 @@ async def logout(
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if token_record:
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
token_record.revoked_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'message': 'Logged out successfully'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -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, _store_refresh_token
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -32,12 +32,22 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
async def _finalize_oauth_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
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
|
||||
|
||||
|
||||
@@ -61,6 +71,9 @@ 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 ---
|
||||
@@ -140,7 +153,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)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
@@ -148,7 +161,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)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
@@ -162,4 +175,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)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
response.started_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
@@ -346,7 +346,7 @@ async def answer_question(
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
response.completed_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -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 ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaign, ReferralEarning, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
@@ -150,12 +150,26 @@ 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:
|
||||
# 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()
|
||||
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
|
||||
|
||||
items.append(
|
||||
ReferralEarningResponse(
|
||||
@@ -165,6 +179,7 @@ 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,
|
||||
)
|
||||
)
|
||||
@@ -194,4 +209,5 @@ 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,
|
||||
)
|
||||
|
||||
@@ -135,7 +135,7 @@ def _subscription_to_response(
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
) -> SubscriptionData:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Use actual_status property for correct status (same as bot uses)
|
||||
actual_status = subscription.actual_status
|
||||
@@ -190,11 +190,15 @@ 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 and tariff_name from tariff (separate from is_daily check)
|
||||
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
|
||||
traffic_reset_mode = None
|
||||
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
|
||||
@@ -235,6 +239,7 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -276,7 +281,7 @@ async def get_subscription(
|
||||
traffic_purchases_data = []
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
purchases_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == fresh_user.subscription.id)
|
||||
@@ -515,7 +520,7 @@ async def renew_subscription(
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
# Extend from end_date or now if expired
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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:
|
||||
@@ -832,9 +837,7 @@ async def purchase_traffic(
|
||||
# Устанавливаем дату сброса трафика (только при первой докупке)
|
||||
# При повторной докупке дата НЕ продлевается
|
||||
if not subscription.traffic_reset_at:
|
||||
from datetime import timedelta
|
||||
|
||||
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
|
||||
subscription.traffic_reset_at = datetime.now(UTC) + timedelta(days=30)
|
||||
logger.info(
|
||||
'Set traffic_reset_at for subscription',
|
||||
subscription_id=subscription.id,
|
||||
@@ -1137,7 +1140,7 @@ async def get_trial_info(
|
||||
|
||||
# Check if user already has an active subscription
|
||||
if user.subscription:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
is_active = (
|
||||
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
|
||||
)
|
||||
@@ -1194,7 +1197,7 @@ async def activate_trial(
|
||||
|
||||
# Check if user already has an active subscription
|
||||
if user.subscription:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
is_active = (
|
||||
user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now
|
||||
)
|
||||
@@ -1484,6 +1487,8 @@ 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
|
||||
@@ -2010,7 +2015,7 @@ async def purchase_tariff(
|
||||
|
||||
# For daily tariffs, set last_daily_charge_at
|
||||
if is_daily_tariff:
|
||||
subscription.last_daily_charge_at = datetime.utcnow()
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.is_daily_paused = False
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -2088,7 +2093,7 @@ async def purchase_tariff(
|
||||
try:
|
||||
# Determine if this is a new subscription or extension
|
||||
was_new_subscription = (
|
||||
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
|
||||
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
|
||||
)
|
||||
notification_type = (
|
||||
NotificationType.SUBSCRIPTION_ACTIVATED
|
||||
@@ -2124,7 +2129,7 @@ async def purchase_tariff(
|
||||
notification_service = AdminNotificationService(bot)
|
||||
# Определяем тип покупки: новая подписка или продление
|
||||
was_new_subscription = (
|
||||
subscription.start_date and (datetime.utcnow() - subscription.start_date).total_seconds() < 60
|
||||
subscription.start_date and (datetime.now(UTC) - subscription.start_date).total_seconds() < 60
|
||||
)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
@@ -2211,8 +2216,6 @@ 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:
|
||||
@@ -2627,8 +2630,6 @@ 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:
|
||||
@@ -3079,9 +3080,7 @@ async def get_available_countries(
|
||||
connected_squads = user.subscription.connected_squads or []
|
||||
# Calculate days left for prorated pricing
|
||||
if user.subscription.end_date:
|
||||
from datetime import datetime
|
||||
|
||||
delta = user.subscription.end_date - datetime.utcnow()
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group
|
||||
@@ -3263,7 +3262,7 @@ async def update_countries(
|
||||
|
||||
# Update connected squads
|
||||
user.subscription.connected_squads = selected_countries
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
@@ -3910,7 +3909,7 @@ async def reduce_devices(
|
||||
|
||||
# Update subscription
|
||||
subscription.device_limit = new_device_limit
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Update RemnaWave
|
||||
@@ -4010,8 +4009,8 @@ async def preview_tariff_switch(
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
|
||||
delta = user.subscription.end_date - datetime.utcnow()
|
||||
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate switch cost
|
||||
@@ -4128,8 +4127,6 @@ 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,
|
||||
@@ -4195,8 +4192,8 @@ async def switch_tariff(
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if user.subscription.end_date and user.subscription.end_date > datetime.utcnow():
|
||||
delta = user.subscription.end_date - datetime.utcnow()
|
||||
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC):
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate cost
|
||||
@@ -4334,14 +4331,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.utcnow() + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = datetime.utcnow()
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.is_daily_paused = False
|
||||
elif switching_from_daily:
|
||||
user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
|
||||
user.subscription.is_daily_paused = False
|
||||
|
||||
user.subscription.updated_at = datetime.utcnow()
|
||||
user.subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
@@ -4425,8 +4422,6 @@ 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:
|
||||
@@ -4476,8 +4471,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.utcnow()
|
||||
user.subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
@@ -4614,7 +4609,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.utcnow()
|
||||
user.subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
@@ -4733,7 +4728,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.utcnow()
|
||||
user.subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Calculate percentage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
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.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
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.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'pending'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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}
|
||||
@@ -9,6 +9,9 @@ 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):
|
||||
@@ -21,6 +24,9 @@ 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):
|
||||
@@ -34,6 +40,9 @@ 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):
|
||||
@@ -41,6 +50,9 @@ 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):
|
||||
@@ -102,6 +114,16 @@ 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."""
|
||||
|
||||
@@ -110,6 +132,7 @@ class AuthResponse(BaseModel):
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
campaign_bonus: CampaignBonusInfo | None = None
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
|
||||
@@ -114,6 +114,7 @@ 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
|
||||
|
||||
@@ -27,6 +27,8 @@ 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:
|
||||
@@ -60,12 +62,16 @@ 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
|
||||
@@ -75,7 +81,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=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool = True
|
||||
# Balance bonus
|
||||
@@ -88,13 +94,15 @@ 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=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType | None = None
|
||||
is_active: bool | None = None
|
||||
# Balance bonus
|
||||
@@ -107,6 +115,8 @@ 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):
|
||||
@@ -147,6 +157,7 @@ class CampaignStatisticsResponse(BaseModel):
|
||||
trial_conversion_rate: float = 0.0
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class CampaignRegistrationItem(BaseModel):
|
||||
@@ -194,6 +205,14 @@ 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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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)
|
||||
@@ -47,6 +47,7 @@ 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:
|
||||
@@ -76,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -56,6 +56,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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 datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -124,7 +124,7 @@ async def save_template_override(
|
||||
)
|
||||
row = existing.fetchone()
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if row:
|
||||
# Update
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -53,6 +54,10 @@ 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,
|
||||
@@ -528,7 +533,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for failed autopay notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ошибка автопродления',
|
||||
@@ -715,7 +720,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for ban notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Аккаунт заблокирован',
|
||||
@@ -783,7 +788,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for warning notification."""
|
||||
message = context.get('message', '')
|
||||
message = html.escape(context.get('message', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Предупреждение',
|
||||
@@ -819,7 +824,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 = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Реферальный бонус: +{bonus}',
|
||||
@@ -856,7 +861,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 = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Новый реферал зарегистрирован',
|
||||
@@ -889,6 +894,249 @@ 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
|
||||
# ============================================================================
|
||||
|
||||
@@ -230,7 +230,9 @@ 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₽)
|
||||
@@ -549,6 +551,7 @@ 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 # По умолчанию старое поведение
|
||||
@@ -670,6 +673,7 @@ 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'
|
||||
|
||||
@@ -8,7 +8,7 @@ from .database import (
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
sync_postgres_sequences,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@ __all__ = [
|
||||
'get_db',
|
||||
'get_db_read_only',
|
||||
'get_pool_metrics',
|
||||
'init_db',
|
||||
'sync_postgres_sequences',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
@@ -36,6 +36,7 @@ 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,
|
||||
@@ -50,6 +51,7 @@ 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)
|
||||
@@ -71,6 +73,7 @@ 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)
|
||||
)
|
||||
@@ -103,6 +106,7 @@ async def get_campaigns_list(
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -141,6 +145,7 @@ async def update_campaign(
|
||||
'tariff_id',
|
||||
'tariff_duration_days',
|
||||
'is_active',
|
||||
'partner_user_id',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@@ -151,7 +156,7 @@ async def update_campaign(
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
|
||||
await db.commit()
|
||||
@@ -331,7 +336,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 = int(amount_kopeks or 0)
|
||||
amount_value = abs(int(amount_kopeks or 0))
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
@@ -359,66 +364,6 @@ 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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
@@ -120,9 +120,7 @@ async def get_user_cryptobot_payments(
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() + timedelta(hours=valid_hours)
|
||||
expires_at = datetime.now(UTC) + 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
|
||||
*,
|
||||
details: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.claimed_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
setting.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
page.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow())
|
||||
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == 'pending',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.status == 'pending',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
template.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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.utcnow())
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
@@ -133,9 +133,7 @@ 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 часов."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
@@ -178,7 +176,7 @@ async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) ->
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
promocode.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
@@ -239,7 +237,7 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.now(UTC).date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(
|
||||
and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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 ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaignRegistration, 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,
|
||||
@@ -18,6 +29,7 @@ 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,
|
||||
@@ -25,6 +37,7 @@ async def create_referral_earning(
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
db.add(earning)
|
||||
@@ -42,7 +55,11 @@ async def get_referral_earnings_by_user(
|
||||
) -> list[ReferralEarning]:
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction))
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction),
|
||||
selectinload(ReferralEarning.campaign),
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -178,7 +195,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today = datetime.now(UTC).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)
|
||||
@@ -190,7 +207,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - 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)
|
||||
)
|
||||
@@ -201,7 +218,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - 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)
|
||||
)
|
||||
@@ -249,7 +266,7 @@ async def get_top_referrers_by_period(
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
if period == 'week':
|
||||
start_date = now - timedelta(days=7)
|
||||
else: # month
|
||||
@@ -380,12 +397,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.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - 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.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, time
|
||||
from datetime import UTC, 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.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
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(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(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.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
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(
|
||||
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
|
||||
abs(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(tx.amount_kopeks for tx in txs_out)
|
||||
total_outside = sum(abs(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(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(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(Transaction.amount_kopeks), 0)).where(
|
||||
subscription_query = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == event.referral_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
rule.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
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.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import random
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
tariff.updated_at = datetime.now(UTC)
|
||||
cleaned_tariffs += 1
|
||||
logger.info(
|
||||
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
|
||||
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
@@ -31,7 +32,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).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||||
elapsed = (datetime.now(UTC) - subscription.last_webhook_update_at).total_seconds()
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
@@ -101,13 +102,13 @@ async def create_trial_subscription(
|
||||
except Exception as error:
|
||||
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
end_date = datetime.now(UTC) + 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.utcnow()
|
||||
existing.start_date = datetime.now(UTC)
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
@@ -124,7 +125,7 @@ async def create_trial_subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=True,
|
||||
start_date=datetime.utcnow(),
|
||||
start_date=datetime.now(UTC),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
@@ -176,7 +177,7 @@ async def create_paid_subscription(
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
@@ -185,7 +186,7 @@ async def create_paid_subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=is_trial,
|
||||
start_date=datetime.utcnow(),
|
||||
start_date=datetime.now(UTC),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
@@ -253,7 +254,7 @@ async def replace_subscription(
|
||||
) -> Subscription:
|
||||
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
old_squads = set(subscription.connected_squads or [])
|
||||
new_squads = set(connected_squads or [])
|
||||
|
||||
@@ -341,7 +342,7 @@ async def extend_subscription(
|
||||
device_limit: Лимит устройств (опционально, для режима тарифов)
|
||||
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
|
||||
"""
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
|
||||
logger.info(
|
||||
@@ -358,45 +359,41 @@ 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:
|
||||
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
# При СМЕНЕ тарифа сохраняем оставшееся время активной подписки
|
||||
# Для триалов — только если включена настройка 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:
|
||||
remaining = subscription.end_date - current_time
|
||||
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)
|
||||
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)
|
||||
subscription.start_date = current_time
|
||||
logger.info('📅 СМЕНА тарифа: срок начинается с текущей даты + дней', total_days=total_days)
|
||||
logger.info(
|
||||
'📅 СМЕНА тарифа: срок начинается с текущей даты + дней + остаток',
|
||||
days=days,
|
||||
remaining_seconds=int(remaining_seconds),
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
# Подписка активна - просто добавляем дни к текущей дате окончания
|
||||
# БЕЗ бонусных дней (они уже учтены в end_date)
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days)
|
||||
else:
|
||||
# Подписка истекла - начинаем с текущей даты + бонус от триала
|
||||
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)
|
||||
# Подписка истекла - начинаем с текущей даты
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', days=days)
|
||||
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
@@ -535,16 +532,12 @@ async def extend_subscription(
|
||||
|
||||
async def add_subscription_traffic(db: AsyncSession, subscription: Subscription, gb: int) -> Subscription:
|
||||
subscription.add_traffic(gb)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Создаём новую запись докупки с индивидуальной датой истечения (30 дней)
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
new_expires_at = datetime.utcnow() + timedelta(days=30)
|
||||
new_expires_at = datetime.now(UTC) + timedelta(days=30)
|
||||
new_purchase = TrafficPurchase(subscription_id=subscription.id, traffic_gb=gb, expires_at=new_expires_at)
|
||||
db.add(new_purchase)
|
||||
|
||||
@@ -553,9 +546,9 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
|
||||
subscription.purchased_traffic_gb = current_purchased + gb
|
||||
|
||||
# Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
active_purchases_query = (
|
||||
sql_select(TrafficPurchase)
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.where(TrafficPurchase.expires_at > now)
|
||||
)
|
||||
@@ -585,7 +578,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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -597,7 +590,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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -612,7 +605,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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -688,7 +681,7 @@ async def update_subscription_autopay(
|
||||
) -> Subscription:
|
||||
subscription.autopay_enabled = enabled
|
||||
subscription.autopay_days_before = days_before
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -700,7 +693,7 @@ async def update_subscription_autopay(
|
||||
|
||||
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -715,7 +708,7 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
@@ -734,16 +727,18 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]:
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_before)
|
||||
threshold_date = datetime.now(UTC) + 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.utcnow(),
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -753,17 +748,25 @@ 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, Subscription.end_date <= datetime.utcnow()))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
@@ -771,6 +774,7 @@ 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,
|
||||
)
|
||||
@@ -811,7 +815,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
paid_subscriptions = active_subscriptions - trial_subscriptions
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.now(UTC).date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= today, Subscription.is_trial == False)
|
||||
@@ -819,7 +823,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
purchased_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= week_ago, Subscription.is_trial == False)
|
||||
@@ -827,7 +831,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
purchased_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= month_ago, Subscription.is_trial == False)
|
||||
@@ -880,7 +884,7 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
async def get_trial_statistics(db: AsyncSession) -> dict:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
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
|
||||
@@ -913,7 +917,7 @@ async def get_trial_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
@@ -970,7 +974,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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -1522,7 +1526,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.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -1532,7 +1536,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.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
logger.info(
|
||||
'🔍 Проверка статуса подписки , текущий статус дата окончания текущее время',
|
||||
@@ -1595,7 +1599,7 @@ async def create_subscription_no_commit(
|
||||
"""
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
end_date = datetime.now(UTC) + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
@@ -1645,7 +1649,7 @@ async def create_subscription(
|
||||
autopay_days_before: int | None = None,
|
||||
) -> Subscription:
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
end_date = datetime.now(UTC) + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
@@ -1693,7 +1697,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.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
@@ -1809,7 +1813,7 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
|
||||
)
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
@@ -1870,7 +1874,7 @@ async def activate_pending_trial_subscription(
|
||||
)
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Обновляем даты
|
||||
@@ -1914,12 +1918,13 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
|
||||
"""
|
||||
from app.database.models import Tariff
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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),
|
||||
@@ -1929,6 +1934,7 @@ 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 часов
|
||||
@@ -1967,6 +1973,7 @@ 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,
|
||||
@@ -2020,8 +2027,8 @@ async def resume_daily_subscription(
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
subscription.last_daily_charge_at = datetime.utcnow()
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
logger.info(
|
||||
'✅ Суточная подписка восстановлена из в ACTIVE',
|
||||
subscription_id=subscription.id,
|
||||
@@ -2044,7 +2051,7 @@ async def update_daily_charge_time(
|
||||
charge_time: datetime = None,
|
||||
) -> Subscription:
|
||||
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
|
||||
now = charge_time or datetime.utcnow()
|
||||
now = charge_time or datetime.now(UTC)
|
||||
subscription.last_daily_charge_at = now
|
||||
|
||||
# Продлеваем подписку на 1 день от текущего момента
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow(),
|
||||
converted_at=datetime.now(UTC),
|
||||
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.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(SubscriptionConversion.converted_at >= month_ago)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow(),
|
||||
occurred_at=occurred_at or datetime.now(UTC),
|
||||
extra=extra or None,
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, or_, select, update
|
||||
@@ -154,18 +154,17 @@ 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
|
||||
return datetime.max.replace(tzinfo=UTC)
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
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.utcnow() else None
|
||||
return max_until if max_until > datetime.now(UTC) else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
@@ -225,7 +224,7 @@ class TicketCRUD:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
@@ -259,14 +258,14 @@ class TicketCRUD:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
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.utcnow())
|
||||
return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.now(UTC))
|
||||
|
||||
@staticmethod
|
||||
async def close_all_open_tickets(
|
||||
@@ -280,7 +279,7 @@ class TicketCRUD:
|
||||
if not ticket_ids:
|
||||
return []
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
await db.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.id.in_(ticket_ids))
|
||||
@@ -417,7 +416,7 @@ class TicketMessageCRUD:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""CRUD operations for TicketNotification."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
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.utcnow())
|
||||
.values(is_read=True, read_at=datetime.now(UTC))
|
||||
)
|
||||
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.utcnow())
|
||||
.values(is_read=True, read_at=datetime.now(UTC))
|
||||
)
|
||||
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.utcnow())
|
||||
.values(is_read=True, read_at=datetime.now(UTC))
|
||||
)
|
||||
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.utcnow())
|
||||
.values(is_read=True, read_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
if is_admin:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() if is_completed else None,
|
||||
completed_at=datetime.now(UTC) 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(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(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.utcnow()
|
||||
transaction.completed_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_date = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
end_date = datetime.now(UTC)
|
||||
|
||||
# Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения)
|
||||
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(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(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.utcnow().date()
|
||||
today = datetime.now(UTC).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.utcnow() - timedelta(days=days)
|
||||
start_date = datetime.now(UTC) - 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.utcnow() - timedelta(hours=24)
|
||||
cutoff_time = datetime.now(UTC) - 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.utcnow().timestamp())
|
||||
timestamp = int(datetime.now(UTC).timestamp())
|
||||
external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}'
|
||||
|
||||
logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id)
|
||||
|
||||
+28
-28
@@ -86,7 +86,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -106,7 +106,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -131,7 +131,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -152,7 +152,7 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Use
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -171,7 +171,7 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -195,7 +195,7 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
if not existing_user:
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
timestamp = str(int(datetime.now(UTC).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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -879,7 +879,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
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.utcnow() - timedelta(days=months * 30)
|
||||
threshold_date = datetime.now(UTC) - timedelta(days=months * 30)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow().date()
|
||||
today = datetime.now(UTC).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.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - 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.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - 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.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
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))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow() > user.email_change_expires:
|
||||
if user.email_change_expires and datetime.now(UTC) > 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.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
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).replace(tzinfo=None)
|
||||
user.updated_at = datetime.now(UTC)
|
||||
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
message.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
message.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
token.updated_at = datetime.now(UTC)
|
||||
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.utcnow())
|
||||
.values(is_active=is_active, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
webhook.updated_at = datetime.now(UTC)
|
||||
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.utcnow() if status == 'success' else None,
|
||||
delivered_at=datetime.now(UTC) 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.utcnow()
|
||||
webhook.last_triggered_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(webhook)
|
||||
return webhook
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
welcome_text.updated_at = datetime.now(UTC)
|
||||
|
||||
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.utcnow()
|
||||
welcome_text.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
CRUD операции для колеса удачи (Fortune Wheel).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()
|
||||
config.updated_at = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
prize.updated_at = datetime.now(UTC)
|
||||
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.utcnow() if is_applied else None,
|
||||
applied_at=datetime.now(UTC) 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.utcnow()
|
||||
spin.applied_at = datetime.now(UTC)
|
||||
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.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(WheelSpin.id)).where(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()}
|
||||
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.now(UTC)}
|
||||
|
||||
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.utcnow())
|
||||
.values(transaction_id=transaction_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
+124
-98
@@ -1,24 +1,25 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import bindparam, event, inspect, text
|
||||
from sqlalchemy import bindparam, event, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
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
|
||||
@@ -67,7 +68,7 @@ _pg_connect_args = {
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
echo='debug' if settings.DEBUG else False,
|
||||
future=True,
|
||||
# Кеш скомпилированных запросов (правильное размещение)
|
||||
query_cache_size=500,
|
||||
@@ -103,7 +104,7 @@ def with_db_retry(
|
||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||
delay: float = DEFAULT_RETRY_DELAY,
|
||||
backoff: float = 2.0,
|
||||
) -> Callable:
|
||||
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||
"""
|
||||
Декоратор для автоматического retry при сбоях подключения к БД.
|
||||
|
||||
@@ -113,10 +114,10 @@ def with_db_retry(
|
||||
backoff: Множитель задержки для каждой следующей попытки
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
last_exception: Exception | None = None
|
||||
current_delay = delay
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
@@ -137,9 +138,9 @@ def with_db_retry(
|
||||
else:
|
||||
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
|
||||
|
||||
raise last_exception
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -150,7 +151,10 @@ async def execute_with_retry(
|
||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||
):
|
||||
"""Выполнение SQL с retry логикой."""
|
||||
last_exception = None
|
||||
if attempts < 1:
|
||||
raise ValueError(f'attempts must be >= 1, got {attempts}')
|
||||
|
||||
last_exception: Exception | None = None
|
||||
delay = DEFAULT_RETRY_DELAY
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
@@ -163,7 +167,7 @@ async def execute_with_retry(
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
raise last_exception
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -201,7 +205,7 @@ def _validate_database_url(url: str | None) -> str | None:
|
||||
return None
|
||||
# Простая проверка на валидный формат
|
||||
if not ('://' in url or url.startswith('sqlite')):
|
||||
logger.warning('Невалидный DATABASE_URL', url=url[:20])
|
||||
logger.warning('Невалидный DATABASE_URL (не содержит ://)')
|
||||
return None
|
||||
return url
|
||||
|
||||
@@ -234,7 +238,10 @@ class DatabaseManager:
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error('Не удалось настроить read replica', e=e)
|
||||
self.read_replica_engine = None
|
||||
@@ -402,88 +409,7 @@ batch_ops = BatchOperations()
|
||||
# ============================================================================
|
||||
|
||||
|
||||
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():
|
||||
async def close_db() -> None:
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info('Закрытие соединений с БД...')
|
||||
|
||||
@@ -495,6 +421,106 @@ async def close_db():
|
||||
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 (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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)
|
||||
+243
-191
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
async with self._lock:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow() + timedelta(seconds=self._cache_seconds)
|
||||
self._expires_at = datetime.now(UTC) + timedelta(seconds=self._cache_seconds)
|
||||
else:
|
||||
self._expires_at = None
|
||||
logger.debug('Получен и закеширован публичный ключ WATA')
|
||||
|
||||
@@ -3,7 +3,7 @@ import io
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}',
|
||||
f'# Generated at {datetime.now(UTC).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.utcnow().strftime("%Y%m%d-%H%M%S")}.env'
|
||||
filename = f'bot-settings-{datetime.now(UTC).strftime("%Y%m%d-%H%M%S")}.env'
|
||||
file = types.BufferedInputFile(content.encode('utf-8'), filename=filename)
|
||||
|
||||
await callback.message.answer_document(
|
||||
|
||||
@@ -399,7 +399,7 @@ async def delete_contest(
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
|
||||
return
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
now_utc = datetime.now(UTC)
|
||||
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).replace(tzinfo=None)
|
||||
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC).replace(tzinfo=None)
|
||||
start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC)
|
||||
end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC)
|
||||
|
||||
contest_type = data.get('contest_type') or 'referral_paid'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
now,
|
||||
ends,
|
||||
)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
now,
|
||||
ends,
|
||||
)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.replace(tzinfo=None),
|
||||
ends.replace(tzinfo=None),
|
||||
now,
|
||||
ends,
|
||||
)
|
||||
started_count += 1
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import html
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
@@ -134,6 +134,7 @@ async def _persist_broadcast_result(
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
blocked_count: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Сохраняет результаты рассылки в НОВОЙ сессии.
|
||||
@@ -147,8 +148,9 @@ async def _persist_broadcast_result(
|
||||
sent_count: Количество успешно отправленных сообщений
|
||||
failed_count: Количество неудачных отправок
|
||||
status: Финальный статус рассылки ('completed', 'partial', 'failed')
|
||||
blocked_count: Количество пользователей, заблокировавших бота
|
||||
"""
|
||||
completed_at = datetime.utcnow()
|
||||
completed_at = datetime.now(UTC)
|
||||
max_retries = 3
|
||||
retry_delay = 1.0
|
||||
|
||||
@@ -164,15 +166,17 @@ 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 status=)',
|
||||
'Результаты рассылки сохранены (id sent failed blocked status=)',
|
||||
broadcast_id=broadcast_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
blocked_count=blocked_count,
|
||||
status=status,
|
||||
)
|
||||
return
|
||||
@@ -309,7 +313,7 @@ async def toggle_pinned_message_position(
|
||||
return
|
||||
|
||||
pinned_message.send_before_menu = not pinned_message.send_before_menu
|
||||
pinned_message.updated_at = datetime.utcnow()
|
||||
pinned_message.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
await show_pinned_message_menu(callback, db_user, db, state)
|
||||
@@ -329,7 +333,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.utcnow()
|
||||
pinned_message.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
await show_pinned_message_menu(callback, db_user, db, state)
|
||||
@@ -1215,8 +1219,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) -> bool:
|
||||
"""Отправляет одно сообщение. Возвращает True при успехе."""
|
||||
async def send_single_broadcast(telegram_id: int) -> str:
|
||||
"""Отправляет одно сообщение. Возвращает 'sent', 'blocked' или 'failed'."""
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_MAX_SEND_RETRIES):
|
||||
@@ -1260,7 +1264,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
return True
|
||||
return 'sent'
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# Глобальная пауза — тормозим все корутины
|
||||
@@ -1276,11 +1280,14 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
return 'blocked'
|
||||
|
||||
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 False
|
||||
return 'failed'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -1293,7 +1300,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 False
|
||||
return 'failed'
|
||||
|
||||
# =========================================================================
|
||||
# Прогресс-бар в реальном времени (как в сканере заблокированных)
|
||||
@@ -1308,26 +1315,29 @@ 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
|
||||
processed = current_sent + current_failed + current_blocked
|
||||
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) -> None:
|
||||
async def _update_progress_message(current_sent: int, current_failed: int, current_blocked: int = 0) -> None:
|
||||
"""Безопасно обновляет сообщение с прогрессом."""
|
||||
nonlocal last_progress_update, progress_message
|
||||
now = asyncio.get_event_loop().time()
|
||||
@@ -1335,7 +1345,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)
|
||||
text = _build_progress_text(current_sent, current_failed, total_recipients, current_blocked=current_blocked)
|
||||
try:
|
||||
await progress_message.edit_text(text, parse_mode='HTML')
|
||||
except TelegramRetryAfter as e:
|
||||
@@ -1357,6 +1367,9 @@ 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
|
||||
# =========================================================================
|
||||
@@ -1369,10 +1382,13 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool):
|
||||
if result:
|
||||
for idx, result in enumerate(results):
|
||||
if isinstance(result, str):
|
||||
if result == 'sent':
|
||||
sent_count += 1
|
||||
elif result == 'blocked':
|
||||
blocked_count += 1
|
||||
blocked_telegram_ids.append(batch[idx])
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
@@ -1381,17 +1397,28 @@ 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)
|
||||
await _update_progress_message(sent_count, failed_count, blocked_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 else 'partial'
|
||||
status = 'completed' if failed_count == 0 and blocked_count == 0 else 'partial'
|
||||
|
||||
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
|
||||
await _persist_broadcast_result(
|
||||
@@ -1399,15 +1426,18 @@ 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'
|
||||
@@ -1449,8 +1479,6 @@ 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
|
||||
@@ -1503,7 +1531,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
if target == 'expiring':
|
||||
# Истекающие в ближайшие 3 дня
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expiry_threshold = now + timedelta(days=3)
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
@@ -1520,7 +1548,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
if target == 'expiring_subscribers':
|
||||
# Истекающие в ближайшие 7 дней
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expiry_threshold = now + timedelta(days=7)
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
@@ -1537,7 +1565,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
@@ -1556,7 +1584,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
@@ -1634,7 +1662,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
# Custom filters — быстрый COUNT вместо загрузки всех пользователей
|
||||
if target.startswith('custom_'):
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
criteria = target[len('custom_') :]
|
||||
|
||||
@@ -1704,7 +1732,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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -1755,7 +1783,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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
@@ -1782,7 +1810,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
]
|
||||
|
||||
if target == 'trial_ending':
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
in_3_days = now + timedelta(days=3)
|
||||
return [
|
||||
user
|
||||
@@ -1794,7 +1822,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
]
|
||||
|
||||
if target == 'trial_expired':
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
@@ -1804,7 +1832,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == 'autopay_failed':
|
||||
from app.database.models import SubscriptionEvent
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
stmt = (
|
||||
select(SubscriptionEvent.user_id)
|
||||
.where(
|
||||
@@ -1826,15 +1854,15 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
]
|
||||
|
||||
if target == 'inactive_30d':
|
||||
threshold = datetime.utcnow() - timedelta(days=30)
|
||||
threshold = datetime.now(UTC) - timedelta(days=30)
|
||||
return [user for user in users if user.last_activity and user.last_activity < threshold]
|
||||
|
||||
if target == 'inactive_60d':
|
||||
threshold = datetime.utcnow() - timedelta(days=60)
|
||||
threshold = datetime.now(UTC) - timedelta(days=60)
|
||||
return [user for user in users if user.last_activity and user.last_activity < threshold]
|
||||
|
||||
if target == 'inactive_90d':
|
||||
threshold = datetime.utcnow() - timedelta(days=90)
|
||||
threshold = datetime.now(UTC) - timedelta(days=90)
|
||||
return [user for user in users if user.last_activity and user.last_activity < threshold]
|
||||
|
||||
# Фильтр по тарифу
|
||||
@@ -1855,7 +1883,7 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -1884,7 +1912,7 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, date, 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()
|
||||
now = datetime.now(UTC)
|
||||
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().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время проверки:</b> {datetime.now(UTC).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().strftime('%H:%M:%S')}
|
||||
🕐 <b>Время проверки:</b> {datetime.now(UTC).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().strftime('%H:%M:%S %d.%m.%Y')}
|
||||
• Время теста: {datetime.now(UTC).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() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - 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() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - 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,8 +1235,6 @@ 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
|
||||
|
||||
@@ -1245,7 +1243,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)
|
||||
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0, tzinfo=UTC)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Получаем старые транзакции без чеков
|
||||
@@ -1311,9 +1309,12 @@ async def receipts_link_old_callback(callback: CallbackQuery):
|
||||
try:
|
||||
from dateutil.parser import isoparse
|
||||
|
||||
t.receipt_created_at = isoparse(operation_time)
|
||||
parsed_time = isoparse(operation_time)
|
||||
t.receipt_created_at = (
|
||||
parsed_time if parsed_time.tzinfo else parsed_time.replace(tzinfo=UTC)
|
||||
)
|
||||
except Exception:
|
||||
t.receipt_created_at = datetime.utcnow()
|
||||
t.receipt_created_at = datetime.now(UTC)
|
||||
linked += 1
|
||||
|
||||
if linked > 0:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
@@ -822,9 +823,7 @@ async def export_payments(
|
||||
file_bytes = json_content.encode('utf-8')
|
||||
|
||||
# Отправляем файл
|
||||
from datetime import datetime
|
||||
|
||||
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
filename = f'payments_export_{datetime.now(UTC).strftime("%Y%m%d_%H%M%S")}.json'
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=BufferedInputFile(file_bytes, filename=filename),
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import html
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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.utcnow()).total_seconds())
|
||||
remaining_seconds = int((offer.expires_at - datetime.now(UTC)).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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() + timedelta(days=expiry_days)
|
||||
valid_until = datetime.now(UTC) + 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.utcnow() + timedelta(days=expiry_days)
|
||||
valid_until = datetime.now(UTC) + 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.utcnow() + timedelta(days=expiry_days)
|
||||
valid_until = datetime.now(UTC) + timedelta(days=expiry_days)
|
||||
|
||||
await update_promocode(db, promo, valid_until=valid_until)
|
||||
|
||||
|
||||
@@ -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.datetime.now().strftime('%H:%M:%S')
|
||||
current_time = datetime.now(UTC).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.datetime.now().strftime('%H:%M:%S')
|
||||
current_time = datetime.now(UTC).strftime('%H:%M:%S')
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
@@ -471,7 +471,9 @@ async def reject_withdrawal_request(callback: types.CallbackQuery, db_user: User
|
||||
await callback.answer('Заявка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
success = await referral_withdrawal_service.reject_request(db, request_id, db_user.id, 'Отклонено администратором')
|
||||
success, _error = await referral_withdrawal_service.reject_request(
|
||||
db, request_id, db_user.id, 'Отклонено администратором'
|
||||
)
|
||||
|
||||
if success:
|
||||
# Уведомляем пользователя (только если есть telegram_id)
|
||||
@@ -512,7 +514,7 @@ async def complete_withdrawal_request(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.answer('Заявка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
success = await referral_withdrawal_service.complete_request(db, request_id, db_user.id, 'Перевод выполнен')
|
||||
success, _error = await referral_withdrawal_service.complete_request(db, request_id, db_user.id, 'Перевод выполнен')
|
||||
|
||||
if success:
|
||||
# Уведомляем пользователя (только если есть telegram_id)
|
||||
@@ -654,27 +656,27 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
)
|
||||
|
||||
|
||||
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
|
||||
def _get_period_dates(period: str) -> tuple[datetime, datetime]:
|
||||
"""Возвращает начальную и конечную даты для заданного периода."""
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if period == 'today':
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
end_date = today + timedelta(days=1)
|
||||
elif period == 'yesterday':
|
||||
start_date = today - datetime.timedelta(days=1)
|
||||
start_date = today - timedelta(days=1)
|
||||
end_date = today
|
||||
elif period == 'week':
|
||||
start_date = today - datetime.timedelta(days=7)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
start_date = today - timedelta(days=7)
|
||||
end_date = today + timedelta(days=1)
|
||||
elif period == 'month':
|
||||
start_date = today - datetime.timedelta(days=30)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
start_date = today - timedelta(days=30)
|
||||
end_date = today + timedelta(days=1)
|
||||
else:
|
||||
# По умолчанию — сегодня
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
end_date = today + timedelta(days=1)
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
@@ -1153,9 +1155,7 @@ async def sync_referrals_with_contest(
|
||||
await callback.answer('🏆 Синхронизирую с конкурсами...')
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
now_utc = datetime.now(UTC)
|
||||
|
||||
# Получаем активные конкурсы
|
||||
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
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()))}
|
||||
🕒 <b>Обновлено:</b> {format_datetime(stats.get('last_updated', datetime.now(UTC)))}
|
||||
"""
|
||||
|
||||
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())}'
|
||||
text += f'\n🕒 <b>Обновлено:</b> {format_datetime(datetime.now(UTC))}'
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_traffic')],
|
||||
@@ -1315,9 +1315,7 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
return
|
||||
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now()
|
||||
end_date = datetime.now(UTC)
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
node_usage = await remnawave_service.get_node_user_usage_by_range(node_uuid, start_date, end_date)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow())
|
||||
current_time = format_datetime(datetime.now(UTC))
|
||||
|
||||
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.utcnow())
|
||||
current_time = format_datetime(datetime.now(UTC))
|
||||
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow())
|
||||
current_time = format_datetime(datetime.now(UTC))
|
||||
|
||||
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.utcnow())
|
||||
current_time = format_datetime(datetime.now(UTC))
|
||||
|
||||
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.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
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.utcnow())
|
||||
current_time = format_datetime(datetime.now(UTC))
|
||||
|
||||
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.utcnow().date() - timedelta(days=1)
|
||||
yesterday = datetime.now(UTC).date() - timedelta(days=1)
|
||||
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
|
||||
elif period == 'today':
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.now(UTC).date()
|
||||
revenue_data = [r for r in revenue_data if r['date'] == today]
|
||||
|
||||
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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)
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime, tz=UTC)
|
||||
|
||||
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).strftime('%d.%m.%Y %H:%M:%S')
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime, tz=UTC).strftime('%d.%m.%Y %H:%M:%S')
|
||||
caption = (
|
||||
f'🧾 Лог-файл <code>{log_path.name}</code>\n📁 Путь: <code>{log_path}</code>\n🕒 Обновлен: {updated_at}'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import html
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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.utcnow() + timedelta(minutes=minutes)
|
||||
until = datetime.now(UTC) + 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.utcnow() + timedelta(minutes=minutes)
|
||||
until = datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if not ok:
|
||||
await message.answer('❌ Ошибка блокировки')
|
||||
|
||||
+16
-19
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -146,7 +146,7 @@ def _build_user_button_text(
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
if user.subscription and user.subscription.end_date:
|
||||
days_left = (user.subscription.end_date - datetime.utcnow()).days
|
||||
days_left = (user.subscription.end_date - datetime.now(UTC)).days
|
||||
button_text += f' | 📅 {days_left}д'
|
||||
|
||||
elif filter_type == UserFilterType.CAMPAIGN:
|
||||
@@ -425,7 +425,7 @@ async def show_users_ready_to_renew(
|
||||
text += 'Нажмите на пользователя для управления:'
|
||||
|
||||
keyboard = []
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
for user in users_data['users']:
|
||||
subscription = user.subscription
|
||||
@@ -730,7 +730,7 @@ async def show_users_statistics(callback: types.CallbackQuery, db_user: User, db
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
active_subscription_query = (
|
||||
select(func.count(Subscription.id))
|
||||
@@ -859,7 +859,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
text += f'<b>Устройства:</b> {subscription.device_limit}\n'
|
||||
|
||||
if subscription.is_active:
|
||||
days_left = (subscription.end_date - datetime.utcnow()).days
|
||||
days_left = (subscription.end_date - datetime.now(UTC)).days
|
||||
text += f'<b>Осталось дней:</b> {days_left}\n'
|
||||
|
||||
current_squads = subscription.connected_squads or []
|
||||
@@ -1558,7 +1558,7 @@ async def _update_referral_commission_percent(
|
||||
return False, None
|
||||
|
||||
user.referral_commission_percent = percent
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -2817,7 +2817,7 @@ async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
earnings_by_referral[referral_id] += earning.amount_kopeks
|
||||
|
||||
referrals_detail = []
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
for referral in referrals:
|
||||
earned = earnings_by_referral.get(referral.id, 0)
|
||||
@@ -3469,7 +3469,7 @@ async def toggle_user_server(callback: types.CallbackQuery, db_user: User, db: A
|
||||
action_text = 'добавлен'
|
||||
|
||||
subscription.connected_squads = current_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -3628,9 +3628,8 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
|
||||
await callback.answer()
|
||||
|
||||
logger.info(
|
||||
'Админ модем для пользователя', telegram_id=db_user.telegram_id, action_text=action_text, user_id=user_id
|
||||
'Админ изменил устройства для пользователя', telegram_id=db_user.telegram_id, devices=devices, user_id=user_id
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -3873,7 +3872,7 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
subscription = user.subscription
|
||||
old_devices = subscription.device_limit
|
||||
subscription.device_limit = devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -3917,7 +3916,7 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
subscription = user.subscription
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -4039,8 +4038,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
|
||||
|
||||
async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
@@ -4051,8 +4048,8 @@ async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id:
|
||||
return False
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
if subscription.end_date <= datetime.utcnow():
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
if subscription.end_date <= datetime.now(UTC):
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -4507,7 +4504,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
return
|
||||
|
||||
if subscription:
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
bonus_period = timedelta()
|
||||
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and subscription.end_date:
|
||||
@@ -5103,7 +5100,7 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st
|
||||
new_type_text = 'триальной' if new_is_trial else 'платной'
|
||||
|
||||
subscription.is_trial = new_is_trial
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
if not new_is_trial and subscription.is_trial:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
@@ -5307,7 +5304,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
subscription.device_limit = tariff.device_limit
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.connected_squads = tariff.allowed_squads or []
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
@@ -251,7 +251,7 @@ async def process_heleket_payment_amount(
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
.values(metadata_json=metadata, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
|
||||
@@ -279,7 +279,7 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db:
|
||||
amount_text = (
|
||||
f'+{texts.format_price(transaction.amount_kopeks)}'
|
||||
if transaction.type == TransactionType.DEPOSIT.value
|
||||
else f'-{texts.format_price(transaction.amount_kopeks)}'
|
||||
else f'-{texts.format_price(abs(transaction.amount_kopeks))}'
|
||||
)
|
||||
|
||||
text += f'{emoji} {amount_text}\n'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import html
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -220,7 +220,7 @@ async def _send_pal24_payment_message(
|
||||
await db.execute(
|
||||
update(payment.__class__)
|
||||
.where(payment.__class__.id == payment.id)
|
||||
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||
.values(metadata_json=metadata, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - diagnostics
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user