Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d16935c1c | |||
| 49d8de76a2 | |||
| b4d8cabbd8 | |||
| a7f3d652c5 | |||
| 38f3a9a16a | |||
| f7d33a7d2b | |||
| bd11801467 | |||
| e512e5fe6e | |||
| 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 |
+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.14.1"
|
||||
".": "3.16.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
|
||||
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
|
||||
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
|
||||
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
|
||||
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.14.1" # x-release-please-version
|
||||
ARG VERSION="v3.16.2" # 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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,6 +27,7 @@ from app.database.crud.user import (
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
@@ -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:
|
||||
@@ -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(
|
||||
@@ -380,6 +447,11 @@ async def auth_telegram_widget(
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -789,6 +866,11 @@ async def login_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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -832,8 +837,6 @@ async def purchase_traffic(
|
||||
# Устанавливаем дату сброса трафика (только при первой докупке)
|
||||
# При повторной докупке дата НЕ продлевается
|
||||
if not subscription.traffic_reset_at:
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
subscription.traffic_reset_at = datetime.now(UTC) + timedelta(days=30)
|
||||
logger.info(
|
||||
'Set traffic_reset_at for subscription',
|
||||
@@ -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
|
||||
@@ -2211,8 +2216,6 @@ async def purchase_devices(
|
||||
)
|
||||
|
||||
# Calculate prorated price based on remaining days
|
||||
from datetime import UTC, 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 UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_date = subscription.end_date
|
||||
if end_date.tzinfo is None:
|
||||
@@ -3079,8 +3080,6 @@ 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 UTC, datetime
|
||||
|
||||
delta = user.subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
@@ -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 UTC, timedelta
|
||||
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -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 UTC, timedelta
|
||||
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
if not user.subscription:
|
||||
|
||||
@@ -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,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',
|
||||
]
|
||||
|
||||
@@ -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 = {}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, select
|
||||
@@ -120,8 +120,6 @@ async def get_user_cryptobot_payments(
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
@@ -133,8 +133,6 @@ 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 UTC, timedelta
|
||||
|
||||
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))
|
||||
|
||||
@@ -5,12 +5,23 @@ 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
@@ -538,10 +535,6 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# Создаём новую запись докупки с индивидуальной датой истечения (30 дней)
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
new_expires_at = datetime.now(UTC) + timedelta(days=30)
|
||||
@@ -555,7 +548,7 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
|
||||
# Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок
|
||||
now = datetime.now(UTC)
|
||||
active_purchases_query = (
|
||||
sql_select(TrafficPurchase)
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.where(TrafficPurchase.expires_at > now)
|
||||
)
|
||||
@@ -738,10 +731,12 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
)
|
||||
@@ -753,8 +748,15 @@ 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.now(UTC)))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -764,6 +766,7 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1920,6 +1924,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
|
||||
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,
|
||||
|
||||
@@ -154,7 +154,6 @@ class TicketCRUD:
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import UTC, datetime
|
||||
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
|
||||
+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)
|
||||
+284
-207
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,7 @@ async def _persist_broadcast_result(
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
blocked_count: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Сохраняет результаты рассылки в НОВОЙ сессии.
|
||||
@@ -147,6 +148,7 @@ async def _persist_broadcast_result(
|
||||
sent_count: Количество успешно отправленных сообщений
|
||||
failed_count: Количество неудачных отправок
|
||||
status: Финальный статус рассылки ('completed', 'partial', 'failed')
|
||||
blocked_count: Количество пользователей, заблокировавших бота
|
||||
"""
|
||||
completed_at = datetime.now(UTC)
|
||||
max_retries = 3
|
||||
@@ -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
|
||||
@@ -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 UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import distinct, func as sql_func
|
||||
|
||||
base_filter = User.status == UserStatus.ACTIVE.value
|
||||
|
||||
@@ -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
|
||||
@@ -1235,8 +1235,6 @@ async def receipts_missing_callback(callback: CallbackQuery):
|
||||
async def receipts_link_old_callback(callback: CallbackQuery):
|
||||
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
|
||||
try:
|
||||
from datetime import UTC, date, timedelta
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
|
||||
@@ -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,8 +823,6 @@ async def export_payments(
|
||||
file_bytes = json_content.encode('utf-8')
|
||||
|
||||
# Отправляем файл
|
||||
from datetime import UTC, datetime
|
||||
|
||||
filename = f'payments_export_{datetime.now(UTC).strftime("%Y%m%d_%H%M%S")}.json'
|
||||
|
||||
await callback.message.answer_document(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
import json
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
@@ -34,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(UTC).strftime('%H:%M:%S')
|
||||
current_time = datetime.now(UTC).strftime('%H:%M:%S')
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
@@ -115,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(UTC).strftime('%H:%M:%S')
|
||||
current_time = datetime.now(UTC).strftime('%H:%M:%S')
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
@@ -472,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)
|
||||
@@ -513,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)
|
||||
@@ -655,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(UTC)
|
||||
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
|
||||
|
||||
@@ -1154,8 +1155,6 @@ async def sync_referrals_with_contest(
|
||||
await callback.answer('🏆 Синхронизирую с конкурсами...')
|
||||
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now_utc = datetime.now(UTC)
|
||||
|
||||
# Получаем активные конкурсы
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -1315,8 +1315,6 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
return
|
||||
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
end_date = datetime.now(UTC)
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
|
||||
@@ -4038,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 UTC, 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
|
||||
|
||||
@@ -520,7 +520,7 @@ async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db:
|
||||
cooldown_days = settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS
|
||||
|
||||
# Проверяем возможность вывода
|
||||
can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
|
||||
can_request, reason, _stats = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
|
||||
|
||||
text = texts.t('REFERRAL_WITHDRAWAL_TITLE', '💸 <b>Вывод реферального баланса</b>') + '\n\n'
|
||||
|
||||
@@ -565,12 +565,12 @@ async def start_withdrawal_request(callback: types.CallbackQuery, db_user: User,
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Повторная проверка
|
||||
can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
|
||||
can_request, reason, wd_stats = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id)
|
||||
if not can_request:
|
||||
await callback.answer(reason, show_alert=True)
|
||||
return
|
||||
|
||||
available = await referral_withdrawal_service.get_available_for_withdrawal(db, db_user.id)
|
||||
available = wd_stats.get('available_total', 0) if wd_stats else 0
|
||||
|
||||
# Сохраняем доступный баланс в состоянии
|
||||
await state.update_data(available_balance=available)
|
||||
@@ -793,7 +793,7 @@ async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_to_admins(admin_text, keyboard=admin_keyboard)
|
||||
await notification_service.send_admin_notification(admin_text, reply_markup=admin_keyboard)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления админам о заявке на вывод', error=e)
|
||||
|
||||
|
||||
+21
-24
@@ -307,7 +307,7 @@ async def _continue_registration_after_language(
|
||||
await state.set_data(data)
|
||||
logger.info('✅ LANGUAGE: Реферер найден', referrer_id=referrer.id)
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code') or data.get('referrer_id'):
|
||||
await _complete_registration_wrapper()
|
||||
else:
|
||||
try:
|
||||
@@ -391,6 +391,12 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
start_parameter=campaign.start_parameter,
|
||||
)
|
||||
await state.update_data(campaign_id=campaign.id)
|
||||
if campaign.partner_user_id:
|
||||
await state.update_data(referrer_id=campaign.partner_user_id)
|
||||
logger.info(
|
||||
'👤 Кампания привязана к партнёру',
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
)
|
||||
else:
|
||||
referral_code = start_parameter
|
||||
logger.info('🔎 Найден реферальный код', referral_code=referral_code)
|
||||
@@ -796,8 +802,8 @@ async def _continue_registration_after_rules(
|
||||
logger.info('✅ Реферер найден', referrer_id=referrer.id)
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
elif settings.SKIP_REFERRAL_CODE:
|
||||
logger.info('⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода')
|
||||
elif settings.SKIP_REFERRAL_CODE or data.get('referrer_id'):
|
||||
logger.info('⚙️ Пропускаем запрос реферального кода')
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
@@ -934,8 +940,8 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
logger.info('✅ Реферер найден', referrer_id=referrer.id)
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
elif settings.SKIP_REFERRAL_CODE:
|
||||
logger.info('⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода')
|
||||
elif settings.SKIP_REFERRAL_CODE or data.get('referrer_id'):
|
||||
logger.info('⚙️ Пропускаем запрос реферального кода')
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
@@ -1177,8 +1183,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
existing_user.last_activity = datetime.now(UTC)
|
||||
|
||||
@@ -1211,8 +1215,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
existing_user.last_activity = datetime.now(UTC)
|
||||
|
||||
@@ -1443,8 +1445,6 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
existing_user.last_activity = datetime.now(UTC)
|
||||
|
||||
@@ -1477,8 +1477,6 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
existing_user.last_activity = datetime.now(UTC)
|
||||
|
||||
@@ -1661,8 +1659,6 @@ def _get_subscription_status(user, texts):
|
||||
subscription = user.subscription
|
||||
actual_status = getattr(subscription, 'actual_status', None)
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
end_date = getattr(subscription, 'end_date', None)
|
||||
end_date_display = format_local_datetime(end_date, '%d.%m.%Y') if end_date else None
|
||||
current_time = datetime.now(UTC)
|
||||
@@ -2017,17 +2013,18 @@ async def required_sub_channel_check(
|
||||
await state.set_data(state_data)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code'):
|
||||
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code') or state_data.get('referrer_id'):
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
# Проверяем реферальный код из ссылки
|
||||
referrer_id = None
|
||||
ref_code_from_link = state_data.get('referral_code')
|
||||
if ref_code_from_link:
|
||||
referrer = await get_user_by_referral_code(db, ref_code_from_link)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
|
||||
# Проверяем реферальный код из ссылки или партнёра кампании
|
||||
referrer_id = state_data.get('referrer_id')
|
||||
if not referrer_id:
|
||||
ref_code_from_link = state_data.get('referral_code')
|
||||
if ref_code_from_link:
|
||||
referrer = await get_user_by_referral_code(db, ref_code_from_link)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -446,8 +447,6 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
|
||||
# Отображаем докупленный трафик
|
||||
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
@@ -348,8 +348,6 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
tariff_info_lines.append('⏸️ <b>Подписка приостановлена</b>')
|
||||
# Показываем оставшееся время даже при паузе
|
||||
if last_charge:
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
next_charge = last_charge + timedelta(hours=24)
|
||||
now = datetime.now(UTC)
|
||||
if next_charge > now:
|
||||
@@ -359,8 +357,6 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
tariff_info_lines.append(f'⏳ Осталось: {hours_left}ч {minutes_left}мин')
|
||||
tariff_info_lines.append('💤 Списание приостановлено')
|
||||
elif last_charge:
|
||||
from datetime import UTC, timedelta
|
||||
|
||||
next_charge = last_charge + timedelta(hours=24)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -4311,8 +4307,6 @@ async def _extend_existing_subscription(
|
||||
squad_uuid: str,
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Покупка подписки по тарифам."""
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
@@ -1251,7 +1251,6 @@ async def confirm_daily_tariff_purchase(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Подтверждает покупку суточного тарифа."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
tariff_id = int(callback.data.split(':')[1])
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
@@ -2022,8 +2021,6 @@ async def select_tariff_switch(
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
days_warning = ''
|
||||
if current_subscription and current_subscription.end_date:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
remaining = current_subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, remaining.days)
|
||||
if remaining_days > 1:
|
||||
@@ -2103,7 +2100,6 @@ async def select_tariff_switch_period(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает выбор периода для переключения тарифа."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
parts = callback.data.split(':')
|
||||
tariff_id = int(parts[1])
|
||||
@@ -2243,8 +2239,8 @@ async def confirm_tariff_switch(
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# При смене тарифа пользователь получает ровно тот период, за который заплатил
|
||||
# Старые дни не сохраняются - это смена тарифа, а не продление
|
||||
# При смене тарифа пользователь получает оплаченный период + оставшиеся дни
|
||||
# (остаток добавляется в extend_subscription автоматически)
|
||||
days_for_new_tariff = period
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
@@ -2361,7 +2357,6 @@ async def confirm_daily_tariff_switch(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Подтверждает смену на суточный тариф."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
tariff_id = int(callback.data.split(':')[1])
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
@@ -2686,7 +2681,6 @@ async def show_instant_switch_list(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Показывает список тарифов для мгновенного переключения."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.clear()
|
||||
@@ -2767,7 +2761,6 @@ async def preview_instant_switch(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Показывает превью мгновенного переключения тарифа."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
tariff_id = int(callback.data.split(':')[1])
|
||||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||||
@@ -2925,7 +2918,6 @@ async def confirm_instant_switch(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Подтверждает мгновенное переключение тарифа."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
tariff_id = int(callback.data.split(':')[1])
|
||||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||||
|
||||
@@ -2033,8 +2033,6 @@ def get_change_devices_keyboard(
|
||||
# Для суточных тарифов считаем по дням, для обычных - по месяцам
|
||||
if is_daily_tariff and subscription_end_date:
|
||||
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
# Множитель = days_left / 30 (как в кабинете)
|
||||
|
||||
+19
-12
@@ -121,24 +121,31 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]:
|
||||
],
|
||||
)
|
||||
|
||||
# Console formatter: colors enabled by default on non-Windows.
|
||||
# Console formatter: colors controlled by LOG_COLORS env var (default: true).
|
||||
# Rich tracebacks with conservative limits to avoid 5000-line dumps.
|
||||
use_colors = settings.LOG_COLORS
|
||||
console_renderer_kwargs: dict[str, Any] = {
|
||||
'colors': use_colors,
|
||||
'pad_event_to': 0,
|
||||
'pad_level': False,
|
||||
}
|
||||
if use_colors:
|
||||
console_renderer_kwargs['exception_formatter'] = structlog.dev.RichTracebackFormatter(
|
||||
show_locals=False,
|
||||
max_frames=20,
|
||||
extra_lines=1,
|
||||
width=120,
|
||||
suppress=['aiogram', 'aiohttp'],
|
||||
)
|
||||
else:
|
||||
console_renderer_kwargs['exception_formatter'] = structlog.dev.plain_traceback
|
||||
|
||||
console_formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=shared_processors,
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
_prefix_logger_name,
|
||||
structlog.dev.ConsoleRenderer(
|
||||
pad_event_to=0,
|
||||
pad_level=False,
|
||||
exception_formatter=structlog.dev.RichTracebackFormatter(
|
||||
show_locals=False,
|
||||
max_frames=20,
|
||||
extra_lines=1,
|
||||
width=120,
|
||||
suppress=['aiogram', 'aiohttp'],
|
||||
),
|
||||
),
|
||||
structlog.dev.ConsoleRenderer(**console_renderer_kwargs),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -1215,6 +1216,12 @@ class AdminNotificationService:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_admin_notification(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None) -> bool:
|
||||
"""Send a generic notification to admin chat with optional inline keyboard."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=reply_markup)
|
||||
|
||||
async def send_webhook_notification(self, text: str) -> bool:
|
||||
"""Send a generic webhook/infrastructure notification to admin chat.
|
||||
|
||||
@@ -1325,8 +1332,6 @@ class AdminNotificationService:
|
||||
if details.get('enabled_at'):
|
||||
enabled_at = details['enabled_at']
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(
|
||||
f'🕐 <b>Время включения:</b> {format_local_datetime(enabled_at, "%d.%m.%Y %H:%M:%S")}'
|
||||
@@ -1342,8 +1347,6 @@ class AdminNotificationService:
|
||||
if details.get('disabled_at'):
|
||||
disabled_at = details['disabled_at']
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(
|
||||
f'🕐 <b>Время отключения:</b> {format_local_datetime(disabled_at, "%d.%m.%Y %H:%M:%S")}'
|
||||
@@ -1446,8 +1449,6 @@ class AdminNotificationService:
|
||||
if details.get('last_check'):
|
||||
last_check = details['last_check']
|
||||
if isinstance(last_check, str):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f'🕐 <b>Последняя проверка:</b> {format_local_datetime(last_check, "%H:%M:%S")}')
|
||||
|
||||
@@ -1629,6 +1630,109 @@ class AdminNotificationService:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
async def send_partner_application_notification(
|
||||
self,
|
||||
user: User,
|
||||
application_data: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Уведомление о новой заявке на партнёрку."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_display = self._get_user_display(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message_lines = [
|
||||
'🤝 <b>ЗАЯВКА НА ПАРТНЁРКУ</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
if application_data.get('company_name'):
|
||||
message_lines.append(f'🏢 Компания: {html.escape(str(application_data["company_name"]))}')
|
||||
if application_data.get('telegram_channel'):
|
||||
message_lines.append(f'📢 Канал: {html.escape(str(application_data["telegram_channel"]))}')
|
||||
if application_data.get('website_url'):
|
||||
message_lines.append(f'🌐 Сайт: {html.escape(str(application_data["website_url"]))}')
|
||||
if application_data.get('description'):
|
||||
desc = str(application_data['description'])
|
||||
if len(desc) > 200:
|
||||
desc = desc[:197] + '...'
|
||||
message_lines.append(f'📝 {html.escape(desc)}')
|
||||
if application_data.get('expected_monthly_referrals'):
|
||||
message_lines.append(f'👥 Ожидаемых рефералов: {application_data["expected_monthly_referrals"]}/мес')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о заявке на партнёрку', error=e)
|
||||
return False
|
||||
|
||||
async def send_withdrawal_request_notification(
|
||||
self,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
payment_details: str | None = None,
|
||||
) -> bool:
|
||||
"""Уведомление о запросе на вывод средств."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
user_display = self._get_user_display(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message_lines = [
|
||||
'💸 <b>ЗАПРОС НА ВЫВОД СРЕДСТВ</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'💵 <b>Сумма: {settings.format_price(amount_kopeks)}</b>',
|
||||
f'💰 Баланс: {settings.format_price(user.balance_kopeks)}',
|
||||
]
|
||||
)
|
||||
|
||||
if payment_details:
|
||||
details = str(payment_details)
|
||||
if len(details) > 200:
|
||||
details = details[:197] + '...'
|
||||
message_lines.extend(['', f'💳 Реквизиты: {html.escape(details)}'])
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления о запросе на вывод', error=e)
|
||||
return False
|
||||
|
||||
async def send_bulk_ban_notification(
|
||||
self,
|
||||
admin_user_id: int,
|
||||
|
||||
@@ -9,10 +9,11 @@ import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import BroadcastHistory
|
||||
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, User, UserStatus
|
||||
from app.handlers.admin.messages import (
|
||||
create_broadcast_keyboard,
|
||||
get_custom_users,
|
||||
@@ -26,6 +27,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Хранение ссылок на фоновые задачи, чтобы GC не удалил их
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
|
||||
@@ -138,10 +142,11 @@ class BroadcastService:
|
||||
) -> None:
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
blocked_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -153,6 +158,7 @@ class BroadcastService:
|
||||
broadcast.status = 'in_progress'
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
broadcast.blocked_count = 0
|
||||
await session.commit()
|
||||
|
||||
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
|
||||
@@ -168,12 +174,12 @@ class BroadcastService:
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
return
|
||||
|
||||
if not recipient_ids:
|
||||
logger.info('Рассылка : получатели не найдены', broadcast_id=broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, blocked_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
@@ -186,7 +192,7 @@ class BroadcastService:
|
||||
TG_BATCH_DELAY=_TG_BATCH_DELAY,
|
||||
)
|
||||
|
||||
sent_count, failed_count, cancelled_during_run = await self._send_batched(
|
||||
sent_count, failed_count, blocked_count, cancelled_during_run = await self._send_batched(
|
||||
broadcast_id,
|
||||
recipient_ids,
|
||||
config,
|
||||
@@ -211,15 +217,16 @@ class BroadcastService:
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
blocked_count,
|
||||
cancelled=False,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str) -> list[int]:
|
||||
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
|
||||
@@ -241,23 +248,28 @@ class BroadcastService:
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
) -> tuple[int, int, int, bool]:
|
||||
"""
|
||||
Единый метод рассылки для любого количества получателей.
|
||||
|
||||
Батчинг по _TG_BATCH_SIZE сообщений с _TG_BATCH_DELAY задержкой.
|
||||
Прогресс обновляется каждые _PROGRESS_UPDATE_MESSAGES сообщений.
|
||||
Глобальная пауза при FloodWait.
|
||||
|
||||
Returns (sent_count, failed_count, blocked_count, was_cancelled).
|
||||
"""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
blocked_count = 0
|
||||
blocked_telegram_ids: list[int] = []
|
||||
|
||||
# Глобальная пауза при FloodWait — все корутины ждут
|
||||
flood_wait_until: float = 0.0
|
||||
last_progress_update: float = 0.0
|
||||
last_progress_count: int = 0
|
||||
|
||||
async def send_single(telegram_id: int) -> bool:
|
||||
async def send_single(telegram_id: int) -> str:
|
||||
"""Returns 'sent', 'blocked', or 'failed'."""
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_TG_MAX_RETRIES):
|
||||
@@ -267,11 +279,11 @@ class BroadcastService:
|
||||
await asyncio.sleep(flood_wait_until - now)
|
||||
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
return 'failed'
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
return 'sent'
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
wait_seconds = e.retry_after + 1
|
||||
@@ -287,10 +299,13 @@ class BroadcastService:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
return 'blocked'
|
||||
|
||||
except TelegramBadRequest:
|
||||
return False
|
||||
except TelegramBadRequest as e:
|
||||
err = str(e).lower()
|
||||
if 'bot was blocked' in err or 'user is deactivated' in err or 'chat not found' in err:
|
||||
return 'blocked'
|
||||
return 'failed'
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
@@ -304,12 +319,12 @@ class BroadcastService:
|
||||
if attempt < _TG_MAX_RETRIES - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False
|
||||
return 'failed'
|
||||
|
||||
for i in range(0, len(recipient_ids), _TG_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
return sent_count, failed_count, blocked_count, True
|
||||
|
||||
batch = recipient_ids[i : i + _TG_BATCH_SIZE]
|
||||
results = await asyncio.gather(
|
||||
@@ -317,10 +332,13 @@ class BroadcastService:
|
||||
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):
|
||||
@@ -328,20 +346,29 @@ class BroadcastService:
|
||||
logger.error('Необработанное исключение в рассылке', broadcast_id=broadcast_id, result=result)
|
||||
|
||||
# Обновляем прогресс в БД периодически
|
||||
processed = sent_count + failed_count
|
||||
processed = sent_count + failed_count + blocked_count
|
||||
now = asyncio.get_event_loop().time()
|
||||
if (
|
||||
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
|
||||
or now - last_progress_update >= _PROGRESS_MIN_INTERVAL_SEC
|
||||
):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count, blocked_count)
|
||||
last_progress_count = processed
|
||||
last_progress_update = now
|
||||
|
||||
# Задержка между батчами для rate limiting
|
||||
await asyncio.sleep(_TG_BATCH_DELAY)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
# Фоновая очистка заблокировавших бота пользователей
|
||||
if blocked_telegram_ids:
|
||||
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)
|
||||
|
||||
return sent_count, failed_count, blocked_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
|
||||
if selected_buttons is None:
|
||||
@@ -392,6 +419,7 @@ class BroadcastService:
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
blocked_count: int = 0,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
@@ -399,7 +427,10 @@ class BroadcastService:
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status='cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial'),
|
||||
blocked_count,
|
||||
status='cancelled'
|
||||
if cancelled
|
||||
else ('completed' if failed_count == 0 and blocked_count == 0 else 'partial'),
|
||||
)
|
||||
|
||||
async def _mark_cancelled(
|
||||
@@ -407,11 +438,13 @@ class BroadcastService:
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
blocked_count: int = 0,
|
||||
) -> None:
|
||||
await self._mark_finished(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
blocked_count,
|
||||
cancelled=True,
|
||||
)
|
||||
|
||||
@@ -420,11 +453,13 @@ class BroadcastService:
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
blocked_count: int = 0,
|
||||
) -> None:
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
blocked_count,
|
||||
status='failed',
|
||||
)
|
||||
|
||||
@@ -433,6 +468,7 @@ class BroadcastService:
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
blocked_count: int = 0,
|
||||
) -> None:
|
||||
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
|
||||
|
||||
@@ -440,6 +476,7 @@ class BroadcastService:
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
blocked_count,
|
||||
status='in_progress',
|
||||
update_completed_at=False,
|
||||
)
|
||||
@@ -449,6 +486,7 @@ class BroadcastService:
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
blocked_count: int = 0,
|
||||
*,
|
||||
status: str,
|
||||
update_completed_at: bool = True,
|
||||
@@ -464,6 +502,7 @@ class BroadcastService:
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.blocked_count = blocked_count
|
||||
broadcast.status = status
|
||||
|
||||
if update_completed_at:
|
||||
@@ -485,6 +524,66 @@ class BroadcastService:
|
||||
return
|
||||
|
||||
|
||||
async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> None:
|
||||
"""
|
||||
Фоновая очистка пользователей, заблокировавших бота (обнаруженных при рассылке).
|
||||
|
||||
Для каждого telegram_id:
|
||||
- Помечает пользователя как BLOCKED
|
||||
- Отключает активные подписки (ACTIVE/TRIAL → DISABLED)
|
||||
- Отключает пользователя в Remnawave панели
|
||||
"""
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
for telegram_id in blocked_telegram_ids:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or user.status == UserStatus.BLOCKED.value:
|
||||
continue
|
||||
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
|
||||
# Отключаем активные подписки
|
||||
sub_result = await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
subscriptions = sub_result.scalars().all()
|
||||
for sub in subscriptions:
|
||||
sub.status = SubscriptionStatus.DISABLED.value
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Отключаем в Remnawave панели (вне транзакции)
|
||||
if user.remnawave_uuid:
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info(
|
||||
'Заблокированный пользователь очищен при рассылке',
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
disabled_subs=len(subscriptions),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка очистки заблокированного пользователя',
|
||||
telegram_id=telegram_id,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
|
||||
|
||||
@@ -150,8 +150,6 @@ class AdvertisingCampaignService:
|
||||
except Exception as error:
|
||||
logger.error('Не удалось подобрать сквад для кампании', campaign_id=campaign.id, error=error)
|
||||
|
||||
squads[0] if squads else None
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
|
||||
@@ -415,10 +415,16 @@ API снова отвечает на запросы.""",
|
||||
self._status.consecutive_failures = status_data.get('consecutive_failures', 0)
|
||||
|
||||
if status_data.get('enabled_at'):
|
||||
self._status.enabled_at = datetime.fromisoformat(status_data['enabled_at'])
|
||||
dt = datetime.fromisoformat(status_data['enabled_at'])
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
self._status.enabled_at = dt
|
||||
|
||||
if status_data.get('last_check'):
|
||||
self._status.last_check = datetime.fromisoformat(status_data['last_check'])
|
||||
dt = datetime.fromisoformat(status_data['last_check'])
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
self._status.last_check = dt
|
||||
|
||||
logger.info('🔥 Состояние техработ загружено из кеша: активен', is_active=self._status.is_active)
|
||||
|
||||
|
||||
@@ -1645,8 +1645,6 @@ class MonitoringService:
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
return
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import structlog
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.models import User, UserStatus
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -53,6 +53,14 @@ class NotificationType(Enum):
|
||||
REFERRAL_BONUS = 'referral_bonus'
|
||||
REFERRAL_REGISTERED = 'referral_registered'
|
||||
|
||||
# Partner notifications
|
||||
PARTNER_APPLICATION_APPROVED = 'partner_application_approved'
|
||||
PARTNER_APPLICATION_REJECTED = 'partner_application_rejected'
|
||||
|
||||
# Withdrawal notifications
|
||||
WITHDRAWAL_APPROVED = 'withdrawal_approved'
|
||||
WITHDRAWAL_REJECTED = 'withdrawal_rejected'
|
||||
|
||||
# Auth emails
|
||||
EMAIL_VERIFICATION = 'email_verification'
|
||||
PASSWORD_RESET = 'password_reset'
|
||||
@@ -140,6 +148,10 @@ class NotificationDeliveryService:
|
||||
Returns:
|
||||
True if notification was sent successfully through at least one channel
|
||||
"""
|
||||
if user.status in (UserStatus.BLOCKED.value, UserStatus.DELETED.value):
|
||||
logger.debug('Пропускаем уведомление для неактивного пользователя', user_id=user.id, status=user.status)
|
||||
return False
|
||||
|
||||
if user.telegram_id:
|
||||
# User has Telegram - send via bot
|
||||
return await self._send_telegram_notification(
|
||||
@@ -273,8 +285,9 @@ class NotificationDeliveryService:
|
||||
logger.warning('Не найден email шаблон для', notification_type_value=notification_type.value)
|
||||
return False
|
||||
|
||||
# Send email
|
||||
success = self.email_service.send_email(
|
||||
# Send email (sync smtplib — run in thread to avoid blocking event loop)
|
||||
success = await asyncio.to_thread(
|
||||
self.email_service.send_email,
|
||||
to_email=user.email,
|
||||
subject=template['subject'],
|
||||
body_html=template['body_html'],
|
||||
@@ -501,6 +514,96 @@ class NotificationDeliveryService:
|
||||
telegram_markup=telegram_markup,
|
||||
)
|
||||
|
||||
async def notify_partner_approved(
|
||||
self,
|
||||
user: User,
|
||||
commission_percent: int,
|
||||
comment: str | None = None,
|
||||
bot: Bot | None = None,
|
||||
telegram_message: str | None = None,
|
||||
) -> bool:
|
||||
"""Notify user about partner application approval."""
|
||||
context = {
|
||||
'commission_percent': commission_percent,
|
||||
'comment': comment or '',
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
user=user,
|
||||
notification_type=NotificationType.PARTNER_APPLICATION_APPROVED,
|
||||
context=context,
|
||||
bot=bot,
|
||||
telegram_message=telegram_message,
|
||||
)
|
||||
|
||||
async def notify_partner_rejected(
|
||||
self,
|
||||
user: User,
|
||||
comment: str | None = None,
|
||||
bot: Bot | None = None,
|
||||
telegram_message: str | None = None,
|
||||
) -> bool:
|
||||
"""Notify user about partner application rejection."""
|
||||
context = {
|
||||
'comment': comment or '',
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
user=user,
|
||||
notification_type=NotificationType.PARTNER_APPLICATION_REJECTED,
|
||||
context=context,
|
||||
bot=bot,
|
||||
telegram_message=telegram_message,
|
||||
)
|
||||
|
||||
async def notify_withdrawal_approved(
|
||||
self,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
comment: str | None = None,
|
||||
bot: Bot | None = None,
|
||||
telegram_message: str | None = None,
|
||||
) -> bool:
|
||||
"""Notify user about withdrawal request approval."""
|
||||
context = {
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'formatted_amount': settings.format_price(amount_kopeks),
|
||||
'comment': comment or '',
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
user=user,
|
||||
notification_type=NotificationType.WITHDRAWAL_APPROVED,
|
||||
context=context,
|
||||
bot=bot,
|
||||
telegram_message=telegram_message,
|
||||
)
|
||||
|
||||
async def notify_withdrawal_rejected(
|
||||
self,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
comment: str | None = None,
|
||||
bot: Bot | None = None,
|
||||
telegram_message: str | None = None,
|
||||
) -> bool:
|
||||
"""Notify user about withdrawal request rejection."""
|
||||
context = {
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'formatted_amount': settings.format_price(amount_kopeks),
|
||||
'comment': comment or '',
|
||||
}
|
||||
|
||||
return await self.send_notification(
|
||||
user=user,
|
||||
notification_type=NotificationType.WITHDRAWAL_REJECTED,
|
||||
context=context,
|
||||
bot=bot,
|
||||
telegram_message=telegram_message,
|
||||
)
|
||||
|
||||
async def notify_daily_debit(
|
||||
self,
|
||||
user: User,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Сервис для обработки заявок на партнёрский статус."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import AdvertisingCampaign, PartnerApplication, PartnerStatus, User
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PartnerApplicationService:
|
||||
"""Сервис управления партнёрскими заявками."""
|
||||
|
||||
async def submit_application(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
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,
|
||||
) -> tuple[PartnerApplication | None, str]:
|
||||
"""
|
||||
Подаёт заявку на партнёрский статус.
|
||||
Возвращает (application, error_message).
|
||||
"""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return None, 'Пользователь не найден'
|
||||
|
||||
if user.partner_status == PartnerStatus.APPROVED.value:
|
||||
return None, 'Вы уже являетесь партнёром'
|
||||
|
||||
if user.partner_status == PartnerStatus.PENDING.value:
|
||||
return None, 'У вас уже есть заявка на рассмотрении'
|
||||
|
||||
application = PartnerApplication(
|
||||
user_id=user_id,
|
||||
company_name=company_name,
|
||||
website_url=website_url,
|
||||
telegram_channel=telegram_channel,
|
||||
description=description,
|
||||
expected_monthly_referrals=expected_monthly_referrals,
|
||||
)
|
||||
|
||||
user.partner_status = PartnerStatus.PENDING.value
|
||||
|
||||
db.add(application)
|
||||
await db.commit()
|
||||
await db.refresh(application)
|
||||
|
||||
logger.info(
|
||||
'📝 Подана заявка на партнёрство',
|
||||
user_id=user_id,
|
||||
application_id=application.id,
|
||||
)
|
||||
|
||||
return application, ''
|
||||
|
||||
async def approve_application(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
application_id: int,
|
||||
admin_id: int,
|
||||
commission_percent: int,
|
||||
comment: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Одобряет заявку на партнёрство.
|
||||
Возвращает (success, error_message).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PartnerApplication).where(PartnerApplication.id == application_id).with_for_update()
|
||||
)
|
||||
application = result.scalar_one_or_none()
|
||||
if not application:
|
||||
return False, 'Заявка не найдена'
|
||||
|
||||
if application.status != PartnerStatus.PENDING.value:
|
||||
return False, 'Заявка уже обработана'
|
||||
|
||||
user_result = await db.execute(select(User).where(User.id == application.user_id).with_for_update())
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False, 'Пользователь не найден'
|
||||
|
||||
# Генерируем реферальный код, если его нет
|
||||
if not user.referral_code:
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id or 0)
|
||||
|
||||
user.partner_status = PartnerStatus.APPROVED.value
|
||||
user.referral_commission_percent = commission_percent
|
||||
|
||||
application.status = PartnerStatus.APPROVED.value
|
||||
application.approved_commission_percent = commission_percent
|
||||
application.admin_comment = comment
|
||||
application.processed_by = admin_id
|
||||
application.processed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'✅ Партнёрская заявка одобрена',
|
||||
application_id=application_id,
|
||||
user_id=application.user_id,
|
||||
commission_percent=commission_percent,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
|
||||
return True, ''
|
||||
|
||||
async def reject_application(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
application_id: int,
|
||||
admin_id: int,
|
||||
comment: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Отклоняет заявку на партнёрство."""
|
||||
result = await db.execute(
|
||||
select(PartnerApplication).where(PartnerApplication.id == application_id).with_for_update()
|
||||
)
|
||||
application = result.scalar_one_or_none()
|
||||
if not application:
|
||||
return False, 'Заявка не найдена'
|
||||
|
||||
if application.status != PartnerStatus.PENDING.value:
|
||||
return False, 'Заявка уже обработана'
|
||||
|
||||
user_result = await db.execute(select(User).where(User.id == application.user_id).with_for_update())
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
user.partner_status = PartnerStatus.REJECTED.value
|
||||
|
||||
application.status = PartnerStatus.REJECTED.value
|
||||
application.admin_comment = comment
|
||||
application.processed_by = admin_id
|
||||
application.processed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'❌ Партнёрская заявка отклонена',
|
||||
application_id=application_id,
|
||||
user_id=application.user_id,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
|
||||
return True, ''
|
||||
|
||||
async def revoke_partner(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int,
|
||||
) -> tuple[bool, str]:
|
||||
"""Отзывает партнёрский статус."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return False, 'Пользователь не найден'
|
||||
|
||||
if user.partner_status != PartnerStatus.APPROVED.value:
|
||||
return False, 'Пользователь не является партнёром'
|
||||
|
||||
user.partner_status = PartnerStatus.NONE.value
|
||||
user.referral_commission_percent = None
|
||||
|
||||
# Отвязываем все кампании от бывшего партнёра
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
.values(partner_user_id=None)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'🚫 Партнёрский статус отозван',
|
||||
user_id=user_id,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
|
||||
return True, ''
|
||||
|
||||
async def get_pending_applications(self, db: AsyncSession) -> list[PartnerApplication]:
|
||||
"""Получает все заявки на рассмотрении."""
|
||||
result = await db.execute(
|
||||
select(PartnerApplication)
|
||||
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
|
||||
.order_by(PartnerApplication.created_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_applications(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[PartnerApplication], int]:
|
||||
"""Получает заявки с фильтрацией. Возвращает (items, total)."""
|
||||
query = select(PartnerApplication)
|
||||
count_query = select(func.count()).select_from(PartnerApplication)
|
||||
|
||||
if status:
|
||||
query = query.where(PartnerApplication.status == status)
|
||||
count_query = count_query.where(PartnerApplication.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(desc(PartnerApplication.created_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
async def get_latest_application(self, db: AsyncSession, user_id: int) -> PartnerApplication | None:
|
||||
"""Получает последнюю заявку пользователя."""
|
||||
result = await db.execute(
|
||||
select(PartnerApplication)
|
||||
.where(PartnerApplication.user_id == user_id)
|
||||
.order_by(desc(PartnerApplication.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# Синглтон сервиса
|
||||
partner_application_service = PartnerApplicationService()
|
||||
@@ -36,25 +36,33 @@ class PartnerStatsService:
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
|
||||
# Базовые данные о рефералах
|
||||
referrals_query = select(User).where(User.referred_by_id == user_id)
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
referrals = referrals_result.scalars().all()
|
||||
referral_ids = [r.id for r in referrals]
|
||||
|
||||
total_referrals = len(referrals)
|
||||
|
||||
# Сколько сделали первое пополнение (has_made_first_topup)
|
||||
paid_referrals = sum(1 for r in referrals if r.has_made_first_topup)
|
||||
# Агрегированная статистика рефералов одним запросом (без загрузки всех User в память)
|
||||
referral_counts_result = await db.execute(
|
||||
select(
|
||||
func.count(User.id).label('total'),
|
||||
func.sum(case((User.has_made_first_topup.is_(True), 1), else_=0)).label('paid'),
|
||||
func.sum(case((User.created_at >= today_start, 1), else_=0)).label('today'),
|
||||
func.sum(case((User.created_at >= week_ago, 1), else_=0)).label('week'),
|
||||
func.sum(case((User.created_at >= month_ago, 1), else_=0)).label('month'),
|
||||
func.sum(case((User.created_at >= year_ago, 1), else_=0)).label('year'),
|
||||
).where(User.referred_by_id == user_id)
|
||||
)
|
||||
ref_row = referral_counts_result.one()
|
||||
total_referrals = int(ref_row.total or 0)
|
||||
paid_referrals = int(ref_row.paid or 0)
|
||||
referrals_today = int(ref_row.today or 0)
|
||||
referrals_week = int(ref_row.week or 0)
|
||||
referrals_month = int(ref_row.month or 0)
|
||||
referrals_year = int(ref_row.year or 0)
|
||||
|
||||
# Активные рефералы (с активной подпиской)
|
||||
if referral_ids:
|
||||
if total_referrals > 0:
|
||||
active_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.id.in_(referral_ids),
|
||||
User.referred_by_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
@@ -90,12 +98,6 @@ class PartnerStatsService:
|
||||
earnings_month = int(earnings_row.month)
|
||||
earnings_year = int(earnings_row.year)
|
||||
|
||||
# Рефералы по периодам
|
||||
referrals_today = sum(1 for r in referrals if r.created_at >= today_start)
|
||||
referrals_week = sum(1 for r in referrals if r.created_at >= week_ago)
|
||||
referrals_month = sum(1 for r in referrals if r.created_at >= month_ago)
|
||||
referrals_year = sum(1 for r in referrals if r.created_at >= year_ago)
|
||||
|
||||
# Конверсии
|
||||
conversion_to_paid = round((paid_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
conversion_to_active = round((active_referrals / total_referrals * 100), 2) if total_referrals > 0 else 0
|
||||
@@ -350,7 +352,6 @@ class PartnerStatsService:
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
year_ago = now - timedelta(days=365)
|
||||
now - timedelta(days=days)
|
||||
|
||||
# Всего рефереров (у кого есть рефералы)
|
||||
total_referrers = await db.execute(
|
||||
|
||||
@@ -61,7 +61,10 @@ class PaymentCommonMixin:
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row:
|
||||
is_active = row.status == 'active' and row.end_date > datetime.now(UTC)
|
||||
end_date = row.end_date
|
||||
if end_date is not None and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
is_active = row.status == 'active' and end_date is not None and end_date > datetime.now(UTC)
|
||||
has_active_subscription = bool(is_active and not row.is_trial)
|
||||
except Exception as db_error:
|
||||
logger.warning(
|
||||
|
||||
@@ -380,7 +380,7 @@ class MulenPayPaymentMixin:
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
'🛒 У вас есть неоформленный заказ.\n\n'
|
||||
'Вы можете продолжить оформление с теми же параметрами.',
|
||||
)
|
||||
).format(total_amount=settings.format_price(payment.amount_kopeks))
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -471,7 +471,7 @@ class PlategaPaymentMixin:
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
'🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.',
|
||||
)
|
||||
).format(total_amount=settings.format_price(payment.amount_kopeks))
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -495,7 +495,7 @@ class TelegramStarsMixin:
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
'🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.',
|
||||
)
|
||||
).format(total_amount=settings.format_price(amount_kopeks))
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -561,7 +561,7 @@ class WataPaymentMixin:
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
'🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.',
|
||||
)
|
||||
).format(total_amount=settings.format_price(payment.amount_kopeks))
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -189,8 +190,6 @@ class PromoCodeService:
|
||||
|
||||
# Обработка DISCOUNT типа (одноразовая скидка)
|
||||
if promocode.type == PromoCodeType.DISCOUNT.value:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
# Проверка на наличие активной скидки
|
||||
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
|
||||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||||
@@ -373,8 +372,6 @@ class PromoCodeService:
|
||||
if current_discount <= 0 or not source or not source.startswith('promocode:'):
|
||||
return {'success': False, 'error': 'no_active_discount_promocode'}
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||||
# Если скидка уже истекла по времени -- тоже нечего деактивировать
|
||||
if expires_at is not None and expires_at <= datetime.now(UTC):
|
||||
|
||||
@@ -18,7 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.crud.referral import create_referral_earning, get_user_campaign_id
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
@@ -802,12 +802,14 @@ class ReferralDiagnosticsService:
|
||||
)
|
||||
|
||||
# Создаём запись ReferralEarning
|
||||
campaign_id = await get_user_campaign_id(db, user.id)
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=inviter_bonus,
|
||||
reason='referral_first_topup',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -1039,12 +1041,14 @@ class ReferralDiagnosticsService:
|
||||
)
|
||||
|
||||
# Создаём ReferralEarning чтобы не начислять повторно
|
||||
campaign_id = await get_user_campaign_id(db, referral.id)
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=referral.id,
|
||||
amount_kopeks=missing.referrer_bonus_amount,
|
||||
reason='referral_first_topup',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
logger.info(
|
||||
'💰 Начислен бонус рефереру ₽',
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.crud.referral import create_referral_earning, get_user_campaign_id
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.models import ReferralEarning, User
|
||||
from app.services.notification_delivery_service import (
|
||||
@@ -74,8 +74,14 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe
|
||||
logger.error('Пользователь не привязан к рефереру', new_user_id=new_user_id, referrer_id=referrer_id)
|
||||
return False
|
||||
|
||||
campaign_id = await get_user_campaign_id(db, new_user_id)
|
||||
await create_referral_earning(
|
||||
db=db, user_id=referrer_id, referral_id=new_user_id, amount_kopeks=0, reason='referral_registration_pending'
|
||||
db=db,
|
||||
user_id=referrer_id,
|
||||
referral_id=new_user_id,
|
||||
amount_kopeks=0,
|
||||
reason='referral_registration_pending',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -132,6 +138,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
logger.error('Реферер не найден', referred_by_id=user.referred_by_id)
|
||||
return False
|
||||
|
||||
campaign_id = await get_user_campaign_id(db, user.id)
|
||||
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||
qualifies_for_first_bonus = topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||
commission_amount = 0
|
||||
@@ -161,6 +168,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason='referral_commission_topup',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -248,6 +256,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
referral_id=user.id,
|
||||
amount_kopeks=inviter_bonus,
|
||||
reason='referral_first_topup',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}'
|
||||
logger.info('💰 Реферер получил бонус ₽', referrer_id=referrer_id, inviter_bonus=inviter_bonus / 100)
|
||||
@@ -283,6 +292,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason='referral_commission_topup',
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}'
|
||||
@@ -339,6 +349,7 @@ async def process_referral_purchase(
|
||||
db, referrer, commission_amount, f'Комиссия {commission_percent}% с покупки {user.full_name}', bot=bot
|
||||
)
|
||||
|
||||
campaign_id = await get_user_campaign_id(db, user.id)
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
@@ -346,6 +357,7 @@ async def process_referral_purchase(
|
||||
amount_kopeks=commission_amount,
|
||||
reason='referral_commission',
|
||||
referral_transaction_id=transaction_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}'
|
||||
|
||||
@@ -171,21 +171,39 @@ class ReferralWithdrawalService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def can_request_withdrawal(self, db: AsyncSession, user_id: int) -> tuple[bool, str]:
|
||||
async def can_request_withdrawal(
|
||||
self, db: AsyncSession, user_id: int, *, stats: dict | None = None
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""
|
||||
Проверяет, может ли пользователь запросить вывод.
|
||||
Возвращает (can_request, reason).
|
||||
Возвращает (can_request, reason, stats).
|
||||
Принимает предвычисленные stats для избежания повторного запроса.
|
||||
"""
|
||||
if not settings.is_referral_withdrawal_enabled():
|
||||
return False, 'Функция вывода реферального баланса отключена'
|
||||
return (
|
||||
False,
|
||||
'Функция вывода реферального баланса отключена',
|
||||
{
|
||||
'total_earned': 0,
|
||||
'own_deposits': 0,
|
||||
'spending': 0,
|
||||
'referral_spent': 0,
|
||||
'withdrawn': 0,
|
||||
'pending': 0,
|
||||
'available_referral': 0,
|
||||
'available_total': 0,
|
||||
'only_referral_mode': settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE,
|
||||
},
|
||||
)
|
||||
|
||||
# Проверяем доступный баланс
|
||||
stats = await self.get_referral_balance_stats(db, user_id)
|
||||
if stats is None:
|
||||
stats = await self.get_referral_balance_stats(db, user_id)
|
||||
available = stats['available_total']
|
||||
min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS
|
||||
|
||||
if available < min_amount:
|
||||
return False, f'Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}₽'
|
||||
return False, f'Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}₽', stats
|
||||
|
||||
# Проверяем cooldown (пропускаем в тестовом режиме)
|
||||
last_request = await self.get_last_withdrawal_request(db, user_id)
|
||||
@@ -197,13 +215,13 @@ class ReferralWithdrawalService:
|
||||
|
||||
if datetime.now(UTC) < cooldown_end:
|
||||
days_left = (cooldown_end - datetime.now(UTC)).days + 1
|
||||
return False, f'Следующий запрос на вывод будет доступен через {days_left} дн.'
|
||||
return False, f'Следующий запрос на вывод будет доступен через {days_left} дн.', stats
|
||||
|
||||
# Проверяем, нет ли активной заявки
|
||||
if last_request.status == WithdrawalRequestStatus.PENDING.value:
|
||||
return False, 'У вас уже есть активная заявка на рассмотрении'
|
||||
return False, 'У вас уже есть активная заявка на рассмотрении', stats
|
||||
|
||||
return True, 'OK'
|
||||
return True, 'OK', stats
|
||||
|
||||
# ==================== АНАЛИЗ НА ОТМЫВАНИЕ ====================
|
||||
|
||||
@@ -244,39 +262,39 @@ class ReferralWithdrawalService:
|
||||
suspicious_referrals = []
|
||||
|
||||
if referral_ids:
|
||||
# Получаем детальную статистику по каждому рефералу за последний месяц
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
|
||||
for ref_id in referral_ids:
|
||||
ref_user = next((r for r in referrals_list if r.id == ref_id), None)
|
||||
ref_name = ref_user.full_name if ref_user else f'ID{ref_id}'
|
||||
|
||||
# Пополнения этого реферала за месяц
|
||||
ref_deposits = await db.execute(
|
||||
select(
|
||||
func.count().label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total'),
|
||||
).where(
|
||||
Transaction.user_id == ref_id,
|
||||
Transaction.type == 'deposit',
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= month_ago,
|
||||
)
|
||||
# Одним запросом получаем статистику пополнений всех рефералов за месяц
|
||||
ref_deposits_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
func.count().label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total'),
|
||||
)
|
||||
deposit_data = ref_deposits.fetchone()
|
||||
deposit_count = deposit_data.count
|
||||
deposit_total = deposit_data.total
|
||||
.where(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.type == 'deposit',
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= month_ago,
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
ref_deposit_map = {row.user_id: (row.count, row.total) for row in ref_deposits_result.all()}
|
||||
|
||||
referrals_by_id = {r.id: r for r in referrals_list}
|
||||
max_deposits = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH
|
||||
min_suspicious = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS
|
||||
|
||||
for ref_id, (deposit_count, deposit_total) in ref_deposit_map.items():
|
||||
ref_user = referrals_by_id.get(ref_id)
|
||||
ref_name = ref_user.full_name if ref_user else f'ID{ref_id}'
|
||||
|
||||
suspicious_flags = []
|
||||
|
||||
# Проверка: слишком много пополнений от одного реферала
|
||||
max_deposits = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH
|
||||
if deposit_count > max_deposits:
|
||||
analysis['risk_score'] += 15
|
||||
suspicious_flags.append(f'{deposit_count} пополнений/мес')
|
||||
|
||||
# Проверка: большие суммы от одного реферала
|
||||
min_suspicious = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS
|
||||
if deposit_total > min_suspicious:
|
||||
analysis['risk_score'] += 10
|
||||
suspicious_flags.append(f'сумма {deposit_total / 100:.0f}₽')
|
||||
@@ -296,7 +314,7 @@ class ReferralWithdrawalService:
|
||||
if suspicious_referrals:
|
||||
analysis['flags'].append(f'⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)')
|
||||
|
||||
# Общая статистика по рефералам
|
||||
# Общая статистика по рефералам (за всё время)
|
||||
all_ref_deposits = await db.execute(
|
||||
select(
|
||||
func.count(func.distinct(Transaction.user_id)).label('paying_count'),
|
||||
@@ -388,13 +406,14 @@ class ReferralWithdrawalService:
|
||||
Создаёт заявку на вывод с анализом на отмывание.
|
||||
Возвращает (request, error_message).
|
||||
"""
|
||||
# Проверяем возможность вывода
|
||||
can_request, reason = await self.can_request_withdrawal(db, user_id)
|
||||
# Блокируем строку пользователя для предотвращения параллельного создания заявок
|
||||
await db.execute(select(User).where(User.id == user_id).with_for_update())
|
||||
|
||||
# Проверяем возможность вывода (stats возвращаются для переиспользования)
|
||||
can_request, reason, stats = await self.can_request_withdrawal(db, user_id)
|
||||
if not can_request:
|
||||
return None, reason
|
||||
|
||||
# Проверяем сумму
|
||||
stats = await self.get_referral_balance_stats(db, user_id)
|
||||
available = stats['available_total']
|
||||
|
||||
if amount_kopeks > available:
|
||||
@@ -446,7 +465,7 @@ class ReferralWithdrawalService:
|
||||
Одобряет заявку на вывод и списывает средства с баланса.
|
||||
Возвращает (success, error_message).
|
||||
"""
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id))
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id).with_for_update())
|
||||
request = result.scalar_one_or_none()
|
||||
|
||||
if not request:
|
||||
@@ -455,13 +474,8 @@ class ReferralWithdrawalService:
|
||||
if request.status != WithdrawalRequestStatus.PENDING.value:
|
||||
return False, 'Заявка уже обработана'
|
||||
|
||||
# Проверяем, что баланс всё ещё достаточен
|
||||
stats = await self.get_referral_balance_stats(db, request.user_id)
|
||||
if request.amount_kopeks > stats['available_total']:
|
||||
return False, f'Недостаточно средств у пользователя. Доступно: {stats["available_total"] / 100:.0f}₽'
|
||||
|
||||
# Получаем пользователя для списания с баланса
|
||||
user_result = await db.execute(select(User).where(User.id == request.user_id))
|
||||
# Получаем пользователя для списания с баланса (с блокировкой строки)
|
||||
user_result = await db.execute(select(User).where(User.id == request.user_id).with_for_update())
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -495,13 +509,16 @@ class ReferralWithdrawalService:
|
||||
|
||||
async def reject_request(
|
||||
self, db: AsyncSession, request_id: int, admin_id: int, comment: str | None = None
|
||||
) -> bool:
|
||||
) -> tuple[bool, str]:
|
||||
"""Отклоняет заявку на вывод."""
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id))
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id).with_for_update())
|
||||
request = result.scalar_one_or_none()
|
||||
|
||||
if not request or request.status != WithdrawalRequestStatus.PENDING.value:
|
||||
return False
|
||||
if not request:
|
||||
return False, 'Заявка не найдена'
|
||||
|
||||
if request.status != WithdrawalRequestStatus.PENDING.value:
|
||||
return False, 'Заявка уже обработана'
|
||||
|
||||
request.status = WithdrawalRequestStatus.REJECTED.value
|
||||
request.processed_by = admin_id
|
||||
@@ -509,17 +526,20 @@ class ReferralWithdrawalService:
|
||||
request.admin_comment = comment
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
return True, ''
|
||||
|
||||
async def complete_request(
|
||||
self, db: AsyncSession, request_id: int, admin_id: int, comment: str | None = None
|
||||
) -> bool:
|
||||
) -> tuple[bool, str]:
|
||||
"""Отмечает заявку как выполненную (деньги переведены)."""
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id))
|
||||
result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id).with_for_update())
|
||||
request = result.scalar_one_or_none()
|
||||
|
||||
if not request or request.status != WithdrawalRequestStatus.APPROVED.value:
|
||||
return False
|
||||
if not request:
|
||||
return False, 'Заявка не найдена'
|
||||
|
||||
if request.status != WithdrawalRequestStatus.APPROVED.value:
|
||||
return False, 'Заявка не в статусе "одобрена"'
|
||||
|
||||
request.status = WithdrawalRequestStatus.COMPLETED.value
|
||||
request.processed_by = admin_id
|
||||
@@ -528,7 +548,7 @@ class ReferralWithdrawalService:
|
||||
request.admin_comment = (request.admin_comment or '') + f'\n{comment}'
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
return True, ''
|
||||
|
||||
# ==================== ФОРМАТИРОВАНИЕ ====================
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
@@ -861,7 +862,6 @@ async def _auto_purchase_daily_tariff(
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Автоматическая покупка суточного тарифа из сохранённой корзины."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
@@ -1526,7 +1526,6 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Attempts to automatically purchase a subscription from a saved cart."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.database.crud.system_setting import (
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SystemSetting
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
from app.services.web_api_token_service import ensure_default_web_api_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -154,7 +154,10 @@ class TrafficMonitoringServiceV2:
|
||||
try:
|
||||
time_str = await cache.get(TRAFFIC_SNAPSHOT_TIME_KEY)
|
||||
if time_str:
|
||||
return datetime.fromisoformat(time_str)
|
||||
dt = datetime.fromisoformat(time_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error('❌ Ошибка получения времени snapshot', error=e)
|
||||
@@ -176,7 +179,10 @@ class TrafficMonitoringServiceV2:
|
||||
key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid)
|
||||
time_str = await cache.get(key)
|
||||
if time_str:
|
||||
return datetime.fromisoformat(time_str)
|
||||
dt = datetime.fromisoformat(time_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error('❌ Ошибка получения времени уведомления', error=e)
|
||||
|
||||
@@ -457,6 +457,7 @@ class UserService:
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaign.id == latest_campaign.c.campaign_id,
|
||||
)
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(
|
||||
AdvertisingCampaign.name.asc(),
|
||||
latest_campaign.c.created_at.desc(),
|
||||
@@ -699,8 +700,6 @@ class UserService:
|
||||
await update_user(db, user, status=UserStatus.ACTIVE.value)
|
||||
|
||||
if user.subscription:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
if user.subscription.end_date > datetime.now(UTC):
|
||||
|
||||
@@ -3,24 +3,119 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud import web_api_token as crud
|
||||
from app.database.models import WebApiToken
|
||||
from app.database.universal_migration import ensure_default_web_api_token
|
||||
from app.utils.security import generate_api_token, hash_api_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def ensure_default_web_api_token() -> bool:
|
||||
"""Ensure the bootstrap web API token from config exists in the DB."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
|
||||
if not default_token:
|
||||
return True
|
||||
|
||||
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or 'Bootstrap Token').strip()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM
|
||||
hmac_secret = settings.WEB_API_TOKEN_HMAC_SECRET
|
||||
token_hash = hash_api_token(default_token, algorithm, hmac_secret=hmac_secret)
|
||||
|
||||
result = await session.execute(select(WebApiToken).where(WebApiToken.token_hash == token_hash))
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
# Fallback: if HMAC enabled, try plain hash (legacy token) and rehash
|
||||
if not existing and hmac_secret:
|
||||
plain_hash = hash_api_token(default_token, algorithm)
|
||||
result = await session.execute(select(WebApiToken).where(WebApiToken.token_hash == plain_hash))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.token_hash = token_hash
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
logger.info('Дефолтный токен перехеширован на HMAC')
|
||||
return True
|
||||
|
||||
if existing:
|
||||
updated = False
|
||||
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
updated = True
|
||||
|
||||
if token_name and existing.name != token_name:
|
||||
existing.name = token_name
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
token = WebApiToken(
|
||||
name=token_name or 'Bootstrap Token',
|
||||
token_hash=token_hash,
|
||||
token_prefix=default_token[:8],
|
||||
description='Автоматически создан при миграции',
|
||||
created_by='migration',
|
||||
is_active=True,
|
||||
)
|
||||
session.add(token)
|
||||
await session.commit()
|
||||
logger.info('Создан дефолтный токен веб-API из конфигурации')
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка создания дефолтного веб-API токена', error=error)
|
||||
return False
|
||||
|
||||
|
||||
class WebApiTokenService:
|
||||
"""Сервис для управления токенами административного веб-API."""
|
||||
|
||||
def __init__(self):
|
||||
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or 'sha256'
|
||||
self.hmac_secret = settings.WEB_API_TOKEN_HMAC_SECRET
|
||||
|
||||
def hash_token(self, token: str) -> str:
|
||||
return hash_api_token(token, self.algorithm, hmac_secret=self.hmac_secret) # type: ignore[arg-type]
|
||||
|
||||
def _hash_token_plain(self, token: str) -> str:
|
||||
"""Hash without HMAC (for legacy fallback)."""
|
||||
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
|
||||
|
||||
async def _load_token_with_fallback(self, db: AsyncSession, value: str) -> WebApiToken | None:
|
||||
"""Load token by hash, falling back to plain hash if HMAC is enabled.
|
||||
|
||||
When HMAC is newly enabled, existing tokens are stored with plain
|
||||
hashes. This method tries HMAC first, then falls back to plain hash
|
||||
and auto-rehashes the token for future lookups.
|
||||
"""
|
||||
token_hash = self.hash_token(value)
|
||||
token = await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
if not token and self.hmac_secret:
|
||||
plain_hash = self._hash_token_plain(value)
|
||||
token = await crud.get_token_by_hash(db, plain_hash)
|
||||
if token:
|
||||
token.token_hash = token_hash
|
||||
token.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
logger.info('Токен автоматически перехеширован на HMAC', token_id=token.id)
|
||||
|
||||
return token
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -32,17 +127,13 @@ class WebApiTokenService:
|
||||
if not normalized_value:
|
||||
return None
|
||||
|
||||
async def _load_token(value: str) -> WebApiToken | None:
|
||||
token_hash = self.hash_token(value)
|
||||
return await crud.get_token_by_hash(db, token_hash)
|
||||
|
||||
token = await _load_token(normalized_value)
|
||||
token = await self._load_token_with_fallback(db, normalized_value)
|
||||
|
||||
if not token:
|
||||
default_token = (settings.WEB_API_DEFAULT_TOKEN or '').strip()
|
||||
if default_token and secrets.compare_digest(default_token, normalized_value):
|
||||
await ensure_default_web_api_token()
|
||||
token = await _load_token(default_token)
|
||||
token = await self._load_token_with_fallback(db, default_token)
|
||||
|
||||
if not token or not token.is_active:
|
||||
return None
|
||||
@@ -73,7 +164,7 @@ class WebApiTokenService:
|
||||
db,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=plain_token[:12],
|
||||
token_prefix=plain_token[:8],
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import structlog
|
||||
from yookassa import Configuration, Payment as YooKassaPayment
|
||||
from yookassa.domain.common.confirmation_type import ConfirmationType
|
||||
from yookassa.domain.exceptions.not_found_error import NotFoundError as YooKassaNotFoundError
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
|
||||
from app.config import settings
|
||||
@@ -412,6 +413,12 @@ class YooKassaService:
|
||||
}
|
||||
logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
|
||||
return None
|
||||
except YooKassaNotFoundError:
|
||||
logger.warning(
|
||||
'Платеж не найден в YooKassa (404)',
|
||||
payment_id_in_yookassa=payment_id_in_yookassa,
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка получения информации о платеже YooKassa',
|
||||
|
||||
@@ -114,6 +114,42 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_cloudpayments_enabled():
|
||||
cloudpayments_name = settings.get_cloudpayments_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'cloudpayments',
|
||||
'name': 'Банковская карта',
|
||||
'icon': '💳',
|
||||
'description': f'через {cloudpayments_name}',
|
||||
'callback': 'topup_cloudpayments',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
freekassa_name = settings.get_freekassa_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'freekassa',
|
||||
'name': freekassa_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {freekassa_name}',
|
||||
'callback': 'topup_freekassa',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_kassa_ai_enabled():
|
||||
kassa_ai_name = settings.get_kassa_ai_display_name()
|
||||
methods.append(
|
||||
{
|
||||
'id': 'kassa_ai',
|
||||
'name': kassa_ai_name,
|
||||
'icon': '💳',
|
||||
'description': f'через {kassa_ai_name}',
|
||||
'callback': 'topup_kassa_ai',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
methods.append(
|
||||
{
|
||||
@@ -225,6 +261,12 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_heleket_enabled()
|
||||
if method_id == 'platega':
|
||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||
if method_id == 'cloudpayments':
|
||||
return settings.is_cloudpayments_enabled()
|
||||
if method_id == 'freekassa':
|
||||
return settings.is_freekassa_enabled()
|
||||
if method_id == 'kassa_ai':
|
||||
return settings.is_kassa_ai_enabled()
|
||||
if method_id == 'support':
|
||||
return settings.is_support_topup_enabled()
|
||||
return False
|
||||
@@ -244,6 +286,9 @@ def get_payment_method_status() -> dict[str, bool]:
|
||||
'cryptobot': settings.is_cryptobot_enabled(),
|
||||
'heleket': settings.is_heleket_enabled(),
|
||||
'platega': settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'kassa_ai': settings.is_kassa_ai_enabled(),
|
||||
'support': settings.is_support_topup_enabled(),
|
||||
}
|
||||
|
||||
@@ -271,4 +316,10 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
count += 1
|
||||
if settings.is_cloudpayments_enabled():
|
||||
count += 1
|
||||
if settings.is_freekassa_enabled():
|
||||
count += 1
|
||||
if settings.is_kassa_ai_enabled():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
+19
-3
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
@@ -10,14 +11,29 @@ from typing import Literal
|
||||
HashAlgorithm = Literal['sha256', 'sha384', 'sha512']
|
||||
|
||||
|
||||
def hash_api_token(token: str, algorithm: HashAlgorithm = 'sha256') -> str:
|
||||
"""Возвращает хеш токена в формате hex."""
|
||||
def hash_api_token(
|
||||
token: str,
|
||||
algorithm: HashAlgorithm = 'sha256',
|
||||
*,
|
||||
hmac_secret: str | None = None,
|
||||
) -> str:
|
||||
"""Возвращает хеш токена в формате hex.
|
||||
|
||||
If ``hmac_secret`` is provided, uses HMAC with the given secret key
|
||||
(recommended for production). Otherwise falls back to plain hash
|
||||
(backward-compatible).
|
||||
"""
|
||||
normalized = (algorithm or 'sha256').lower()
|
||||
if normalized not in {'sha256', 'sha384', 'sha512'}:
|
||||
raise ValueError(f'Unsupported hash algorithm: {algorithm}')
|
||||
|
||||
token_bytes = token.encode('utf-8')
|
||||
|
||||
if hmac_secret:
|
||||
return hmac.new(hmac_secret.encode('utf-8'), token_bytes, normalized).hexdigest()
|
||||
|
||||
digest = getattr(hashlib, normalized)
|
||||
return digest(token.encode('utf-8')).hexdigest()
|
||||
return digest(token_bytes).hexdigest()
|
||||
|
||||
|
||||
def generate_api_token(length: int = 48) -> str:
|
||||
|
||||
@@ -37,6 +37,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
blocked_count=broadcast.blocked_count or 0,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -553,7 +554,6 @@ async def export_menu_layout(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuLayoutExportResponse:
|
||||
"""Экспортировать конфигурацию меню."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
export_data = await MenuLayoutService.export_config(db)
|
||||
|
||||
@@ -730,7 +730,6 @@ async def get_menu_click_stats(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MenuClickStatsResponse:
|
||||
"""Получить общую статистику кликов по всем кнопкам."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
stats = await MenuLayoutService.get_all_buttons_stats(db, days)
|
||||
total_clicks = await MenuLayoutService.get_total_clicks(db, days)
|
||||
|
||||
@@ -93,6 +93,7 @@ class BroadcastResponse(BaseModel):
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
blocked_count: int = 0
|
||||
status: str
|
||||
admin_id: int | None = None
|
||||
admin_name: str | None = None
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
- `app/database/models.py` — Python-модуль
|
||||
Классы: `UserStatus`, `SubscriptionStatus`, `TransactionType`, `PromoCodeType`, `PaymentMethod`, `MainMenuButtonActionType`, `MainMenuButtonVisibility`, `YooKassaPayment` (6 методов), `CryptoBotPayment` (5 методов), `MulenPayPayment` (2 методов), `Pal24Payment` (3 методов), `PromoGroup` (3 методов), `User` (5 методов), `Subscription` (11 методов), `Transaction` (1 методов), `SubscriptionConversion` (2 методов), `PromoCode` (2 методов), `PromoCodeUse`, `ReferralEarning` (1 методов), `Squad` (1 методов), `ServiceRule`, `PrivacyPolicy`, `PublicOffer`, `FaqSetting`, `FaqPage`, `SystemSetting`, `MonitoringLog`, `SentNotification`, `DiscountOffer`, `PromoOfferTemplate`, `SubscriptionTemporaryAccess`, `PromoOfferLog`, `BroadcastHistory`, `ServerSquad` (3 методов), `SubscriptionServer`, `SupportAuditLog`, `UserMessage` (1 методов), `WelcomeText`, `AdvertisingCampaign` (2 методов), `AdvertisingCampaignRegistration` (1 методов), `TicketStatus`, `Ticket` (8 методов), `TicketMessage` (3 методов), `WebApiToken` (1 методов), `MainMenuButton` (3 методов)
|
||||
Функции: нет
|
||||
- `app/database/universal_migration.py` — Python-модуль
|
||||
- `app/database/migrations.py` — Programmatic Alembic migration runner
|
||||
Классы: нет
|
||||
Функции: нет
|
||||
Функции: `run_alembic_upgrade`, `stamp_alembic_head`
|
||||
|
||||
#### app/database/crud
|
||||
|
||||
|
||||
@@ -41,13 +41,12 @@ API разворачивается вместе с ботом, использу
|
||||
## 3. Подготовка базы данных
|
||||
|
||||
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
|
||||
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
|
||||
- создаёт таблицу `web_api_tokens`, если её нет;
|
||||
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
|
||||
3. Если нужно запустить миграцию вручную, выполните:
|
||||
2. При старте бота автоматически запускаются Alembic-миграции (`alembic upgrade head`), которые создают все необходимые таблицы, включая `web_api_tokens`.
|
||||
3. Токен из `WEB_API_DEFAULT_TOKEN` активируется автоматически при запуске.
|
||||
4. Если нужно запустить миграцию вручную, выполните:
|
||||
|
||||
```bash
|
||||
python -c "import asyncio; from app.database.universal_migration import run_universal_migration; asyncio.run(run_universal_migration())"
|
||||
make migrate # или: uv run alembic upgrade head
|
||||
```
|
||||
|
||||
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
|
||||
|
||||
@@ -12,9 +12,9 @@ sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
from app.bot import setup_bot
|
||||
from app.config import settings
|
||||
from app.database.database import init_db
|
||||
from app.database.database import sync_postgres_sequences
|
||||
from app.database.migrations import run_alembic_upgrade
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
from app.logging_config import setup_logging
|
||||
from app.services.backup_service import backup_service
|
||||
@@ -41,6 +41,7 @@ from app.services.reporting_service import reporting_service
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
|
||||
from app.services.version_service import version_service
|
||||
from app.services.web_api_token_service import ensure_default_web_api_token
|
||||
from app.utils.log_handlers import ExcludePaymentFilter, LevelFilterHandler
|
||||
from app.utils.payment_logger import configure_payment_logger
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
@@ -179,42 +180,43 @@ async def main():
|
||||
summary_logged = False
|
||||
|
||||
try:
|
||||
async with timeline.stage('Инициализация базы данных', '🗄️', success_message='База данных готова'):
|
||||
await init_db()
|
||||
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
async with timeline.stage(
|
||||
'Проверка и миграция базы данных',
|
||||
'Миграция базы данных (Alembic)',
|
||||
'🧬',
|
||||
success_message='Миграция завершена успешно',
|
||||
) as stage:
|
||||
try:
|
||||
migration_log = logging.getLogger('app.database.universal_migration')
|
||||
original_level = migration_log.level
|
||||
migration_log.setLevel(logging.WARNING)
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
finally:
|
||||
migration_log.setLevel(original_level)
|
||||
if migration_success:
|
||||
stage.success('Миграция завершена успешно')
|
||||
else:
|
||||
stage.warning('Миграция завершилась с предупреждениями, запуск продолжится')
|
||||
logger.warning('⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск')
|
||||
await run_alembic_upgrade()
|
||||
stage.success('Миграция завершена успешно')
|
||||
except Exception as migration_error:
|
||||
stage.warning(f'Ошибка выполнения миграции: {migration_error}')
|
||||
logger.error('❌ Ошибка выполнения миграции', migration_error=migration_error)
|
||||
logger.warning('⚠️ Продолжаем запуск без миграции')
|
||||
allow_failure = os.getenv('ALLOW_MIGRATION_FAILURE', 'false').lower() == 'true'
|
||||
logger.error('Ошибка выполнения миграции', migration_error=migration_error)
|
||||
if not allow_failure:
|
||||
raise
|
||||
stage.warning(f'Ошибка миграции: {migration_error} (ALLOW_MIGRATION_FAILURE=true)')
|
||||
else:
|
||||
timeline.add_manual_step(
|
||||
'Проверка и миграция базы данных',
|
||||
'Миграция базы данных (Alembic)',
|
||||
'⏭️',
|
||||
'Пропущено',
|
||||
'SKIP_MIGRATION=true',
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
'Инициализация базы данных',
|
||||
'🗄️',
|
||||
success_message='База данных готова',
|
||||
) as stage:
|
||||
seq_ok = await sync_postgres_sequences()
|
||||
token_ok = await ensure_default_web_api_token()
|
||||
if not seq_ok:
|
||||
stage.warning('Не удалось синхронизировать последовательности PostgreSQL')
|
||||
if not token_ok:
|
||||
stage.warning('Не удалось создать/проверить дефолтный веб-API токен')
|
||||
|
||||
async with timeline.stage(
|
||||
'Синхронизация тарифов из конфига',
|
||||
'💰',
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -16,21 +16,28 @@ from app.config import settings
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
# Only apply fileConfig when running via CLI (make migrate, make migration).
|
||||
# When called programmatically from run_alembic_upgrade(), structlog is already
|
||||
# configured — fileConfig would replace root logger handlers and break logging.
|
||||
import logging as _logging
|
||||
|
||||
if config.config_file_name is not None and not _logging.root.handlers:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
# URL also set in app/database/migrations.py for programmatic usage;
|
||||
# this line is needed for CLI invocation (make migrate, make migration).
|
||||
config.set_main_option('sqlalchemy.url', settings.get_database_url())
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
url = config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@@ -47,7 +54,7 @@ def do_run_migrations(connection: Connection) -> None:
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
@@ -58,10 +65,14 @@ async def run_async_migrations() -> None:
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
# asyncio.run() is safe here: when called programmatically via
|
||||
# run_alembic_upgrade(), this runs inside run_in_executor() which
|
||||
# creates a separate thread with no event loop, so asyncio.run()
|
||||
# can create a fresh loop without conflict.
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
run_migrations_online()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-02-18
|
||||
|
||||
Creates all tables from SQLAlchemy models via metadata.create_all.
|
||||
For existing databases, use ``alembic stamp head`` to mark as current.
|
||||
|
||||
NOTE: This migration uses create_all(checkfirst=True) which is coupled to
|
||||
the current state of models.py. Future migrations MUST use explicit
|
||||
op.create_table() / op.add_column() calls. If you need to bootstrap a
|
||||
fresh database AND have later migrations, run this migration first,
|
||||
then apply subsequent migrations normally — checkfirst=True prevents
|
||||
duplicate table errors.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.database.models import Base
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise NotImplementedError(
|
||||
'Downgrading the initial schema is not supported. '
|
||||
'Restore from a database backup instead.'
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""add campaign_id to referral_earnings
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-02-18
|
||||
|
||||
Adds campaign_id FK to referral_earnings table and backfills
|
||||
existing rows from advertising_campaign_registrations.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0002'
|
||||
down_revision: Union[str, None] = '0001'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add column (idempotent check)
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [c['name'] for c in inspector.get_columns('referral_earnings')]
|
||||
|
||||
if 'campaign_id' not in columns:
|
||||
op.add_column('referral_earnings', sa.Column('campaign_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_referral_earnings_campaign_id',
|
||||
'referral_earnings',
|
||||
'advertising_campaigns',
|
||||
['campaign_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
op.create_index('ix_referral_earnings_campaign_id', 'referral_earnings', ['campaign_id'])
|
||||
|
||||
# Backfill existing data — pick earliest campaign registration per user
|
||||
# (matches runtime logic in get_user_campaign_id: ORDER BY created_at ASC LIMIT 1)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE referral_earnings re
|
||||
SET campaign_id = sub.campaign_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (user_id) user_id, campaign_id
|
||||
FROM advertising_campaign_registrations
|
||||
ORDER BY user_id, created_at ASC
|
||||
) sub
|
||||
WHERE sub.user_id = re.referral_id
|
||||
AND re.campaign_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_referral_earnings_campaign_id', table_name='referral_earnings')
|
||||
op.drop_constraint('fk_referral_earnings_campaign_id', 'referral_earnings', type_='foreignkey')
|
||||
op.drop_column('referral_earnings', 'campaign_id')
|
||||
@@ -0,0 +1,110 @@
|
||||
"""add partner system tables and columns
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-02-18
|
||||
|
||||
Adds partner_status to users, creates withdrawal_requests and
|
||||
partner_applications tables, adds partner_user_id to advertising_campaigns,
|
||||
adds blocked_count to broadcast_history.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0003'
|
||||
down_revision: Union[str, None] = '0002'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return column in [c['name'] for c in inspector.get_columns(table)]
|
||||
|
||||
|
||||
def _has_table(table: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return table in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users.partner_status
|
||||
if not _has_column('users', 'partner_status'):
|
||||
op.add_column('users', sa.Column('partner_status', sa.String(20), nullable=False, server_default='none'))
|
||||
op.create_index('ix_users_partner_status', 'users', ['partner_status'])
|
||||
|
||||
# 2. broadcast_history.blocked_count
|
||||
if _has_table('broadcast_history') and not _has_column('broadcast_history', 'blocked_count'):
|
||||
op.add_column('broadcast_history', sa.Column('blocked_count', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
# 3. advertising_campaigns.partner_user_id
|
||||
if _has_table('advertising_campaigns') and not _has_column('advertising_campaigns', 'partner_user_id'):
|
||||
op.add_column('advertising_campaigns', sa.Column('partner_user_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
'fk_advertising_campaigns_partner_user_id',
|
||||
'advertising_campaigns',
|
||||
'users',
|
||||
['partner_user_id'],
|
||||
['id'],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
op.create_index('ix_advertising_campaigns_partner_user_id', 'advertising_campaigns', ['partner_user_id'])
|
||||
|
||||
# 4. withdrawal_requests table
|
||||
if not _has_table('withdrawal_requests'):
|
||||
op.create_table(
|
||||
'withdrawal_requests',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(50), nullable=False, server_default='pending', index=True),
|
||||
sa.Column('payment_details', sa.Text(), nullable=True),
|
||||
sa.Column('risk_score', sa.Integer(), server_default='0'),
|
||||
sa.Column('risk_analysis', sa.Text(), nullable=True),
|
||||
sa.Column('processed_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('admin_comment', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# 5. partner_applications table
|
||||
if not _has_table('partner_applications'):
|
||||
op.create_table(
|
||||
'partner_applications',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
'user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False
|
||||
),
|
||||
sa.Column('company_name', sa.String(255), nullable=True),
|
||||
sa.Column('website_url', sa.String(500), nullable=True),
|
||||
sa.Column('telegram_channel', sa.String(255), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('expected_monthly_referrals', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
|
||||
sa.Column('admin_comment', sa.Text(), nullable=True),
|
||||
sa.Column('approved_commission_percent', sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
'processed_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
||||
),
|
||||
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('partner_applications')
|
||||
op.drop_table('withdrawal_requests')
|
||||
op.drop_index('ix_advertising_campaigns_partner_user_id', table_name='advertising_campaigns')
|
||||
op.drop_constraint('fk_advertising_campaigns_partner_user_id', 'advertising_campaigns', type_='foreignkey')
|
||||
op.drop_column('advertising_campaigns', 'partner_user_id')
|
||||
op.drop_column('broadcast_history', 'blocked_count')
|
||||
op.drop_index('ix_users_partner_status', table_name='users')
|
||||
op.drop_column('users', 'partner_status')
|
||||
@@ -1,57 +0,0 @@
|
||||
"""add pinned start mode and user last pin
|
||||
|
||||
Revision ID: 1b2e3d4f5a6b
|
||||
Revises: 7a3c0b8f5b84
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1b2e3d4f5a6b'
|
||||
down_revision = '7a3c0b8f5b84'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
if not _table_exists(inspector, table_name):
|
||||
return False
|
||||
columns = {col["name"] for col in inspector.get_columns(table_name)}
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector, "pinned_messages"):
|
||||
if not _column_exists(inspector, "pinned_messages", "send_on_every_start"):
|
||||
op.add_column(
|
||||
'pinned_messages',
|
||||
sa.Column('send_on_every_start', sa.Boolean(), nullable=False, server_default='1'),
|
||||
)
|
||||
|
||||
if _table_exists(inspector, "users"):
|
||||
if not _column_exists(inspector, "users", "last_pinned_message_id"):
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('last_pinned_message_id', sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, "users", "last_pinned_message_id"):
|
||||
op.drop_column('users', 'last_pinned_message_id')
|
||||
|
||||
if _column_exists(inspector, "pinned_messages", "send_on_every_start"):
|
||||
op.drop_column('pinned_messages', 'send_on_every_start')
|
||||
@@ -1,224 +0,0 @@
|
||||
"""add promo groups table and link users"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
PROMO_GROUPS_TABLE = "promo_groups"
|
||||
USERS_TABLE = "users"
|
||||
PROMO_GROUP_COLUMN = "promo_group_id"
|
||||
PROMO_GROUP_INDEX = "ix_users_promo_group_id"
|
||||
PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups"
|
||||
DEFAULT_PROMO_GROUP_NAME = "Базовый юзер"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
revision: str = "1f5f3a3f5a4d"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.create_table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"server_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"traffic_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"device_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("name", name="uq_promo_groups_name"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
op.add_column(
|
||||
USERS_TABLE,
|
||||
sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.create_foreign_key(
|
||||
PROMO_GROUP_FK,
|
||||
USERS_TABLE,
|
||||
PROMO_GROUPS_TABLE,
|
||||
[PROMO_GROUP_COLUMN],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists(
|
||||
inspector, USERS_TABLE, PROMO_GROUP_COLUMN
|
||||
):
|
||||
return
|
||||
|
||||
promo_groups_table = sa.table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("name", sa.String()),
|
||||
sa.column("server_discount_percent", sa.Integer()),
|
||||
sa.column("traffic_discount_percent", sa.Integer()),
|
||||
sa.column("device_discount_percent", sa.Integer()),
|
||||
sa.column("is_default", sa.Boolean()),
|
||||
)
|
||||
|
||||
connection = bind
|
||||
existing_named_group = (
|
||||
connection.execute(
|
||||
sa.select(
|
||||
promo_groups_table.c.id,
|
||||
promo_groups_table.c.is_default,
|
||||
)
|
||||
.where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME)
|
||||
.limit(1)
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_named_group:
|
||||
default_group_id = existing_named_group["id"]
|
||||
if not existing_named_group["is_default"]:
|
||||
connection.execute(
|
||||
sa.update(promo_groups_table)
|
||||
.where(promo_groups_table.c.id == default_group_id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
default_group_id = connection.execute(
|
||||
sa.select(promo_groups_table.c.id)
|
||||
.where(promo_groups_table.c.is_default.is_(True))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if default_group_id is None:
|
||||
default_group_id = connection.execute(
|
||||
sa.insert(promo_groups_table)
|
||||
.values(
|
||||
name=DEFAULT_PROMO_GROUP_NAME,
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
.returning(promo_groups_table.c.id)
|
||||
).scalar_one()
|
||||
|
||||
users_table = sa.table(
|
||||
USERS_TABLE,
|
||||
sa.column("promo_group_id", sa.Integer()),
|
||||
)
|
||||
connection.execute(
|
||||
sa.update(users_table)
|
||||
.where(users_table.c.promo_group_id.is_(None))
|
||||
.values(promo_group_id=default_group_id)
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
column_info = next(
|
||||
(col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN),
|
||||
None,
|
||||
)
|
||||
if column_info and column_info.get("nullable", True):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
column_info = next(
|
||||
(
|
||||
col
|
||||
for col in inspector.get_columns(USERS_TABLE)
|
||||
if col["name"] == PROMO_GROUP_COLUMN
|
||||
),
|
||||
None,
|
||||
)
|
||||
if column_info and not column_info.get("nullable", False):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey")
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE)
|
||||
|
||||
op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.drop_table(PROMO_GROUPS_TABLE)
|
||||
@@ -1,95 +0,0 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "2b3c1d4e5f6a"
|
||||
down_revision: Union[str, None] = "9f0f2d5a1c7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"platega_payments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
|
||||
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"currency",
|
||||
sa.String(length=10),
|
||||
nullable=False,
|
||||
server_default="RUB",
|
||||
),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("payment_method_code", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
),
|
||||
sa.Column(
|
||||
"is_paid",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("redirect_url", sa.Text(), nullable=True),
|
||||
sa.Column("return_url", sa.Text(), nullable=True),
|
||||
sa.Column("failed_url", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.String(length=255), nullable=True),
|
||||
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||
sa.Column("callback_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("transaction_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
|
||||
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
"platega_payments",
|
||||
["platega_transaction_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_correlation_id",
|
||||
"platega_payments",
|
||||
["correlation_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_transaction_id",
|
||||
"platega_payments",
|
||||
["transaction_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
|
||||
op.drop_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
table_name="platega_payments",
|
||||
)
|
||||
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
|
||||
op.drop_table("platega_payments")
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "4b6b0f58c8f9"
|
||||
down_revision: Union[str, None] = "1f5f3a3f5a4d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dialect = bind.dialect.name if bind else ""
|
||||
|
||||
op.add_column(
|
||||
"promo_groups",
|
||||
sa.Column("period_discounts", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL")
|
||||
else:
|
||||
op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("promo_groups", "period_discounts")
|
||||
@@ -1,146 +0,0 @@
|
||||
"""add advertising campaigns tables"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
CAMPAIGNS_TABLE = "advertising_campaigns"
|
||||
CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter"
|
||||
CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id"
|
||||
REGISTRATIONS_TABLE = "advertising_campaign_registrations"
|
||||
REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
revision: str = "5d1f1f8b2e9a"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.create_table(
|
||||
CAMPAIGNS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.create_index(
|
||||
CAMPAIGNS_START_INDEX,
|
||||
CAMPAIGNS_TABLE,
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.create_table(
|
||||
REGISTRATIONS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_id"],
|
||||
[f"{CAMPAIGNS_TABLE}.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.create_index(
|
||||
REGISTRATIONS_ID_INDEX,
|
||||
REGISTRATIONS_TABLE,
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.drop_table(REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.drop_table(CAMPAIGNS_TABLE)
|
||||
@@ -1,75 +0,0 @@
|
||||
"""add media fields to pinned messages"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "5f2a3e099427"
|
||||
down_revision: Union[str, None] = "c9c71d04f0a1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "pinned_messages"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_missing(inspector: sa.Inspector, column_name: str) -> bool:
|
||||
columns = {column.get("name") for column in inspector.get_columns(TABLE_NAME)}
|
||||
return column_name not in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
if _column_missing(inspector, "media_type"):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column("media_type", sa.String(length=32), nullable=True),
|
||||
)
|
||||
|
||||
if _column_missing(inspector, "media_file_id"):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column("media_file_id", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
# Ensure content has a default value for media-only messages
|
||||
op.alter_column(
|
||||
TABLE_NAME,
|
||||
"content",
|
||||
existing_type=sa.Text(),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
if not _column_missing(inspector, "media_type"):
|
||||
op.drop_column(TABLE_NAME, "media_type")
|
||||
|
||||
if not _column_missing(inspector, "media_file_id"):
|
||||
op.drop_column(TABLE_NAME, "media_file_id")
|
||||
|
||||
op.alter_column(
|
||||
TABLE_NAME,
|
||||
"content",
|
||||
existing_type=sa.Text(),
|
||||
nullable=False,
|
||||
server_default=None,
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""add send_before_menu to pinned messages
|
||||
|
||||
Revision ID: 7a3c0b8f5b84
|
||||
Revises: 5f2a3e099427
|
||||
Create Date: 2025-02-05 00:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7a3c0b8f5b84"
|
||||
down_revision = "5f2a3e099427"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
TABLE_NAME = "pinned_messages"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, column_name: str) -> bool:
|
||||
if not _table_exists(inspector):
|
||||
return False
|
||||
columns = {col["name"] for col in inspector.get_columns(TABLE_NAME)}
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
if _column_exists(inspector, "send_before_menu"):
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(
|
||||
"send_before_menu",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, "send_before_menu"):
|
||||
op.drop_column(TABLE_NAME, "send_before_menu")
|
||||
@@ -1,58 +0,0 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = 'sent_notifications'
|
||||
UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications'
|
||||
UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before']
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def _unique_constraint_exists(inspector: Inspector) -> bool:
|
||||
existing_constraints = {
|
||||
constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME)
|
||||
}
|
||||
return UNIQUE_CONSTRAINT_NAME in existing_constraints
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME),
|
||||
)
|
||||
elif not _unique_constraint_exists(inspector):
|
||||
op.create_unique_constraint(
|
||||
UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
op.drop_table(TABLE_NAME)
|
||||
@@ -1,155 +0,0 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "9f0f2d5a1c7b"
|
||||
down_revision: Union[str, None] = "8fd1e338eb45"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"polls",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_polls_id", "polls", ["id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_questions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_questions_id", "poll_questions", ["id"])
|
||||
op.create_index("ix_poll_questions_poll_id", "poll_questions", ["poll_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_options",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("text", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"order",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
)
|
||||
op.create_index("ix_poll_options_id", "poll_options", ["id"])
|
||||
op.create_index("ix_poll_options_question_id", "poll_options", ["question_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_responses",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("poll_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"sent_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"reward_given",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"reward_amount_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["poll_id"], ["polls.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
|
||||
)
|
||||
op.create_index("ix_poll_responses_id", "poll_responses", ["id"])
|
||||
op.create_index("ix_poll_responses_poll_id", "poll_responses", ["poll_id"])
|
||||
op.create_index("ix_poll_responses_user_id", "poll_responses", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"poll_answers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("response_id", sa.Integer(), nullable=False),
|
||||
sa.Column("question_id", sa.Integer(), nullable=False),
|
||||
sa.Column("option_id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["option_id"], ["poll_options.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["question_id"], ["poll_questions.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["response_id"], ["poll_responses.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
|
||||
)
|
||||
op.create_index("ix_poll_answers_id", "poll_answers", ["id"])
|
||||
op.create_index("ix_poll_answers_response_id", "poll_answers", ["response_id"])
|
||||
op.create_index("ix_poll_answers_question_id", "poll_answers", ["question_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_poll_answers_question_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_response_id", table_name="poll_answers")
|
||||
op.drop_index("ix_poll_answers_id", table_name="poll_answers")
|
||||
op.drop_table("poll_answers")
|
||||
|
||||
op.drop_index("ix_poll_responses_user_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_poll_id", table_name="poll_responses")
|
||||
op.drop_index("ix_poll_responses_id", table_name="poll_responses")
|
||||
op.drop_table("poll_responses")
|
||||
|
||||
op.drop_index("ix_poll_options_question_id", table_name="poll_options")
|
||||
op.drop_index("ix_poll_options_id", table_name="poll_options")
|
||||
op.drop_table("poll_options")
|
||||
|
||||
op.drop_index("ix_poll_questions_poll_id", table_name="poll_questions")
|
||||
op.drop_index("ix_poll_questions_id", table_name="poll_questions")
|
||||
op.drop_table("poll_questions")
|
||||
|
||||
op.drop_index("ix_polls_id", table_name="polls")
|
||||
op.drop_table("polls")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""add purchased_traffic_gb to subscriptions
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2024-12-25 14:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f4a5b6c7d8e9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
|
||||
op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
# Устанавливаем NOT NULL после добавления значения по умолчанию
|
||||
op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('subscriptions', 'purchased_traffic_gb')
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Add webhooks and webhook_deliveries tables"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "e3c1e0b5b4a7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
WEBHOOKS_TABLE = "webhooks"
|
||||
DELIVERIES_TABLE = "webhook_deliveries"
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Создаем таблицу webhooks
|
||||
if not _table_exists(inspector, WEBHOOKS_TABLE):
|
||||
op.create_table(
|
||||
WEBHOOKS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("url", sa.Text(), nullable=False),
|
||||
sa.Column("secret", sa.String(length=128), nullable=True),
|
||||
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("last_triggered_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("failure_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
op.create_index("ix_webhooks_event_type", WEBHOOKS_TABLE, ["event_type"])
|
||||
op.create_index("ix_webhooks_is_active", WEBHOOKS_TABLE, ["is_active"])
|
||||
|
||||
# Создаем таблицу webhook_deliveries
|
||||
if not _table_exists(inspector, DELIVERIES_TABLE):
|
||||
op.create_table(
|
||||
DELIVERIES_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"webhook_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("webhooks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("response_status", sa.Integer(), nullable=True),
|
||||
sa.Column("response_body", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("attempt_number", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("delivered_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("next_retry_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_webhook_deliveries_webhook_created",
|
||||
DELIVERIES_TABLE,
|
||||
["webhook_id", "created_at"],
|
||||
)
|
||||
op.create_index("ix_webhook_deliveries_status", DELIVERIES_TABLE, ["status"])
|
||||
op.create_index("ix_webhook_deliveries_webhook_id", DELIVERIES_TABLE, ["webhook_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Удаляем таблицу webhook_deliveries
|
||||
if _table_exists(inspector, DELIVERIES_TABLE):
|
||||
op.drop_index("ix_webhook_deliveries_webhook_id", table_name=DELIVERIES_TABLE)
|
||||
op.drop_index("ix_webhook_deliveries_status", table_name=DELIVERIES_TABLE)
|
||||
op.drop_index(
|
||||
"ix_webhook_deliveries_webhook_created",
|
||||
table_name=DELIVERIES_TABLE,
|
||||
)
|
||||
op.drop_table(DELIVERIES_TABLE)
|
||||
|
||||
# Удаляем таблицу webhooks
|
||||
if _table_exists(inspector, WEBHOOKS_TABLE):
|
||||
op.drop_index("ix_webhooks_is_active", table_name=WEBHOOKS_TABLE)
|
||||
op.drop_index("ix_webhooks_event_type", table_name=WEBHOOKS_TABLE)
|
||||
op.drop_table(WEBHOOKS_TABLE)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Add subscription_events table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = "c2f9c3b5f5c4"
|
||||
down_revision: Union[str, None] = "9f0f2d5a1c7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "subscription_events"
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"subscription_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("subscriptions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"transaction_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("transactions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("amount_kopeks", sa.Integer(), nullable=True),
|
||||
sa.Column("currency", sa.String(length=16), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"occurred_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("extra", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_subscription_events_event_type", TABLE_NAME, ["event_type"]
|
||||
)
|
||||
op.create_index("ix_subscription_events_user_id", TABLE_NAME, ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.drop_index("ix_subscription_events_user_id", table_name=TABLE_NAME)
|
||||
op.drop_index("ix_subscription_events_event_type", table_name=TABLE_NAME)
|
||||
op.drop_table(TABLE_NAME)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""add pinned messages table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c9c71d04f0a1"
|
||||
down_revision: Union[str, None] = "e3c1e0b5b4a7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = "pinned_messages"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), default=True),
|
||||
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
op.drop_table(TABLE_NAME)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user