Compare commits

...

59 Commits

Author SHA1 Message Date
Egor 8d16935c1c Merge pull request #2629 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.2
2026-02-18 11:18:08 +03:00
github-actions[bot] 49d8de76a2 chore(main): release 3.16.2 2026-02-18 08:17:02 +00:00
Egor b4d8cabbd8 Merge pull request #2628 from BEDOLAGA-DEV/dev
Dev
2026-02-18 11:16:34 +03:00
Fringg a7f3d652c5 fix: use AwareDateTime TypeDecorator for all datetime columns
TypeDecorator with process_result_value guarantees naive datetimes
from pre-TIMESTAMPTZ databases are converted to UTC-aware on every
load. Replaces unreliable event listener approach. All 175 DateTime
columns now use AwareDateTime.
2026-02-18 11:11:58 +03:00
Fringg 38f3a9a16a fix: handle naive datetime in raw SQL row comparison (payment/common) 2026-02-18 11:02:09 +03:00
Fringg f7d33a7d2b fix: auto-convert naive datetimes to UTC-aware on model load
SQLAlchemy event listener on Base ensures all DateTime columns are
timezone-aware after loading from DB. Fixes TypeError crashes in
50+ comparison sites across handlers, services, and middlewares
for pre-TIMESTAMPTZ databases.
2026-02-18 11:01:04 +03:00
Fringg bd11801467 fix: extend naive datetime guard to all model properties
Move _aware() to module level and apply to 4 more models:
- PromoCode.is_valid (valid_from, valid_until)
- TrafficPurchase.is_expired (expires_at)
- CabinetRefreshToken.is_expired (expires_at)
- Ticket.is_user_reply_blocked (user_reply_block_until)
2026-02-18 10:44:13 +03:00
Fringg e512e5fe6e fix: handle naive datetimes in Subscription properties
Databases that haven't run the TIMESTAMPTZ migration return naive
datetimes from end_date. Comparing with datetime.now(UTC) raises
TypeError. Added _aware() helper to normalize naive→aware in
is_active, is_expired, should_be_expired, actual_status, days_left,
time_left_display, and extend_subscription.
2026-02-18 10:36:46 +03:00
Egor 799c83dd84 Merge pull request #2627 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.1
2026-02-18 10:29:59 +03:00
github-actions[bot] 4cc18cbc9a chore(main): release 3.16.1 2026-02-18 07:29:30 +00:00
Egor 4645be53cb Merge pull request #2626 from BEDOLAGA-DEV/dev
fix: add migration for partner system tables and columns
2026-02-18 10:29:04 +03:00
Fringg 79ea398d1d fix: add migration for partner system tables and columns
Existing databases stamped at 0001 (create_all checkfirst=True) are
missing new columns/tables from the partner system:
- users.partner_status
- broadcast_history.blocked_count
- advertising_campaigns.partner_user_id
- withdrawal_requests table
- partner_applications table

All checks are idempotent — safe for fresh and existing databases.
2026-02-18 10:26:07 +03:00
Egor 30b1402b54 Merge pull request #2625 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.16.0
2026-02-18 09:57:20 +03:00
github-actions[bot] 15d848c1ca chore(main): release 3.16.0 2026-02-18 06:56:54 +00:00
Egor c9877a3cbe Merge pull request #2624 from BEDOLAGA-DEV/dev
Dev
2026-02-18 09:56:08 +03:00
Fringg 68499ee043 chore: ruff format 2026-02-18 09:51:56 +03:00
Fringg bdb61613de fix: add missing payment providers to payment_utils and fix {total_amount} formatting
- Add freekassa, cloudpayments, kassa_ai to get_available_payment_methods(),
  is_payment_method_available(), get_payment_method_status(), and
  get_enabled_payment_methods_count()
- Fix cart reminder message showing literal {total_amount} in platega,
  stars, mulenpay, wata by adding .format() call
2026-02-18 09:50:36 +03:00
Fringg 59383bdbd8 feat: expose traffic_reset_mode in subscription response 2026-02-18 09:41:33 +03:00
Fringg 5d4a94b8ce feat: expose traffic_reset_mode in tariff API response 2026-02-18 09:36:36 +03:00
Fringg 0c07812ecc feat: add campaign_id to ReferralEarning for campaign attribution
Adds nullable FK campaign_id to referral_earnings table, enabling
direct campaign ROI analytics without JOINing through registrations.

- Model: campaign_id column + AdvertisingCampaign relationship
- CRUD: get_user_campaign_id() helper, campaign_id param in create_referral_earning
- Service: resolve campaign_id in all earning creation paths
- Cabinet API: campaign_name in earnings response
- Migration 0002: add column + deterministic backfill via DISTINCT ON
2026-02-18 09:12:01 +03:00
Fringg eb9dba3f47 fix: add selectinload for subscription in campaign user list
Prevents MissingGreenlet error when accessing user.subscription
in the admin campaign users filter view.
2026-02-18 08:42:53 +03:00
Fringg 6c4e035146 fix: correct subscription_service import in broadcast cleanup
Import SubscriptionService class and instantiate locally, matching
the pattern used throughout the codebase.
2026-02-18 08:39:50 +03:00
Fringg e78b1040a5 fix: prevent fileConfig from destroying structlog handlers
Only apply alembic.ini logging config when root logger has no handlers
(CLI mode). When running programmatically, structlog is already configured
and fileConfig would replace its handlers, breaking all logging.
2026-02-18 08:25:38 +03:00
Egor b6c7f91a7c Merge pull request #2623 from BEDOLAGA-DEV/refactor/alembic-migration
refactor: replace universal_migration.py with Alembic
2026-02-18 08:13:56 +03:00
Fringg e998059d81 style: format admin_campaigns, admin_partners, referral_withdrawal_service 2026-02-18 08:13:04 +03:00
Fringg 764e063bfe style: apply ruff formatting 2026-02-18 08:11:33 +03:00
Fringg 784616b349 refactor: replace universal_migration.py with Alembic
Remove the 7,791-line universal_migration.py and 16 incomplete individual
Alembic migrations. Replace with a single initial schema migration using
Base.metadata.create_all(checkfirst=True).

Changes:
- Add programmatic Alembic runner (app/database/migrations.py) with
  auto-stamp logic for existing databases transitioning from
  universal_migration
- Extract ensure_default_web_api_token() to web_api_token_service.py
- Extract sync_postgres_sequences() to database.py with SQL injection
  prevention via _quote_ident()
- Add HMAC token hashing support with backward-compatible dual-hash
  fallback and automatic rehashing
- Remove dead init_db() function and unused imports
- Add Makefile targets: migrate, migration, migrate-stamp, migrate-history
- Fix fileConfig() destroying structlog config (disable_existing_loggers)
- Remove duplicate migrations/alembic/alembic.ini with credentials
- Add script.py.mako template for future migration generation
- Update startup flow: alembic upgrade → sync sequences → ensure token
- Harden database.py: ParamSpec for retry decorator, safe URL logging,
  echo='debug' mode, execute_with_retry validation
- Update documentation references

31 files changed, 302 insertions(+), 9,226 deletions(-)
2026-02-18 08:10:20 +03:00
Fringg b4b10c998c fix: add blocked_count column migration to universal_migration.py
The column existed in the SQLAlchemy model and Alembic migration but was
missing from universal_migration.py which is used for auto-migrations on
startup, causing "column broadcast_history.blocked_count does not exist"
error in the broadcasts admin page.
2026-02-18 06:57:03 +03:00
Fringg 366df18c54 feat: enforce 1-to-1 partner-campaign binding with partner info in campaigns
- Add partner_user_id/partner_name to campaign list and detail responses
- Add partner_user_id to campaign create/update schemas
- Add GET /available-partners endpoint for partner dropdown
- Atomic assign with UPDATE...WHERE to prevent race conditions
- Validate partner exists and is approved in create/update
- Set updated_at on assign/unassign operations
- Eager-load partner relationship in campaign queries
2026-02-18 06:47:02 +03:00
Fringg 7883efc3d6 fix: return zeroed stats dict when withdrawal is disabled
can_request_withdrawal returned empty dict {} when withdrawal feature
was disabled, causing KeyError on 'total_earned' in withdrawal route.
2026-02-18 05:37:32 +03:00
Fringg 6881d97bbb feat: add admin partner settings API (withdrawal toggle, requisites text, partner visibility)
- GET/PATCH /admin/partners/settings endpoints with .env persistence
- New config: REFERRAL_WITHDRAWAL_REQUISITES_TEXT, REFERRAL_PARTNER_SECTION_VISIBLE
- Serve requisites_text in withdrawal balance and partner_section_visible in referral terms
- Sanitize newlines in requisites_text before .env write to prevent injection
2026-02-18 04:12:15 +03:00
Fringg 90278f1f5f style: fix ruff formatting in broadcast_service and tests 2026-02-17 18:50:25 +03:00
Fringg df5b1a072d fix: handle YooKassa NotFoundError gracefully in get_payment_info
Catch NotFoundError (404) separately from generic exceptions.
Old/expired payments return 404 from YooKassa API — this is expected
and should be logged as WARNING without traceback, not ERROR.
2026-02-17 18:46:32 +03:00
Fringg 10e231e52e feat: blocked user detection during broadcasts, filter blocked from all notifications
- Broadcast tri-state return: 'sent'/'blocked'/'failed' with blocked_count tracking
- Background cleanup: mark blocked users + disable their subscriptions + Remnawave
- blocked_count in BroadcastHistory model, schemas, API responses, admin UI
- Filter User.status==ACTIVE in subscription queries: get_expiring_subscriptions,
  get_expired_subscriptions, get_subscriptions_for_autopay,
  get_daily_subscriptions_for_charge, get_disabled_daily_subscriptions_for_resume
- Guard in notification_delivery_service.send_notification for BLOCKED/DELETED users
- Fix subscription tariff switch: preserve remaining days with total_seconds()
- Fix redundant local UTC imports across 16 files
- Fix test mocks: add **kwargs, correct assertion, remove dead expression
2026-02-17 18:37:25 +03:00
Fringg 7c20fde4e8 fix: medium-priority fixes for partner system
- replace unsafe referral code generator with unique DB-checked version
- remove dead code in get_global_partner_stats
- validate status filter params with Literal types in admin routes
2026-02-17 12:42:40 +03:00
Fringg fcf3a2c806 fix: resolve HIGH-priority performance and security issues in partner system
- fix N+1 query in money laundering analysis with GROUP BY batch query
- fix N+1 query in cabinet referral earnings with batch user fetch
- eliminate double balance stats computation in withdrawal flow
- replace in-memory referral counting with SQL COUNT/CASE aggregation
- fix HTML injection in admin Telegram notifications via html.escape()
- standardize return types for reject/complete withdrawal methods
2026-02-17 12:38:25 +03:00
Fringg 88997492c3 fix: critical security and data integrity fixes for partner system
- Add SELECT FOR UPDATE locking on all financial state transitions
  (withdrawal approve/reject/complete/create, partner approve/reject)
- Add html.escape() on all user-controlled values in email templates
- Wrap sync SMTP send_email in asyncio.to_thread to avoid blocking event loop
- Add missing database indexes on referral_earnings(user_id, referral_id),
  users(referred_by_id, partner_status), withdrawal_requests(user_id, status),
  advertising_campaigns(partner_user_id)
2026-02-17 12:28:30 +03:00
Fringg 327d4f4d15 feat: notify users on partner/withdrawal approve/reject
4 notification types via NotificationDeliveryService:
- Partner application approved/rejected
- Withdrawal request approved/rejected

Telegram + email + WebSocket routing handled automatically.
Email templates in ru/en/zh/ua.
2026-02-17 12:04:23 +03:00
Fringg cf7cc5a84e feat: add admin notifications for partner applications and withdrawals
Send notifications to admin chat when a partner application is submitted
or a withdrawal request is created, following existing notification pattern.
2026-02-17 11:48:38 +03:00
Fringg 28f524b762 fix: campaign web link uses ?campaign= param, not ?start=
The cabinet frontend captures ?campaign= from URL (campaign.ts utility),
not ?start=. Fixed the partner-facing link from /login?start= to /?campaign=.
2026-02-17 11:36:40 +03:00
Fringg c4dc43e054 feat: link campaign registrations to partner for referral earnings
Two separate fixes for bot and cabinet auth paths:

Bot (start.py): store referrer_id from campaign.partner_user_id in FSM
state, skip referral code prompt when partner already set.

Cabinet (auth.py): in _process_campaign_bonus, set user.referred_by_id
to campaign.partner_user_id and call process_referral_registration.

Both paths now correctly attribute campaign users to the partner,
enabling commission earnings from their future purchases.
2026-02-17 11:33:31 +03:00
Fringg 767e965028 feat: attribute campaign registrations to partner for referral earnings
When a user registers through a campaign link that has partner_user_id,
store that partner as referrer_id in FSM state. This connects the
campaign system to the referral earning system — the partner now earns
commissions from all purchases made by users who came through their
campaign links.

Changes in all registration paths:
- cmd_start: store referrer_id from campaign.partner_user_id
- language/rules/privacy handlers: skip referral code prompt when
  referrer_id already set from campaign
- channel check: pick up referrer_id from state instead of hardcoding None
2026-02-17 11:22:38 +03:00
Fringg d39063b22f fix: unassign all campaigns when revoking partner status
Previously revoke_partner only changed partner_status and commission,
leaving campaigns orphaned with invalid partner_user_id. Now sets
partner_user_id=NULL on all campaigns belonging to the revoked partner.
2026-02-17 11:11:25 +03:00
Fringg ea5d932476 feat: include partner campaigns in /partner/status response
Return assigned active campaigns with bonus info, deep_link and
web_link so the partner's referral page can display shareable links.
2026-02-17 10:45:11 +03:00
Fringg acc1323a54 fix: move PartnerStatus enum before User class to fix NameError
PartnerStatus was defined after the User class that references it,
causing a NameError on startup.
2026-02-17 09:56:11 +03:00
Fringg 58bfaeaddb feat: add partner system and withdrawal management to cabinet
- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
2026-02-17 09:51:36 +03:00
Fringg df5415f30b fix: reorder button_click_logs migration to nullify before ALTER TYPE
ALTER COLUMN user_id TYPE INTEGER failed with "integer out of range"
because the column contained telegram_id values (BIGINT) exceeding
INTEGER max. Swapped order: SET NULL first, then ALTER TYPE.
2026-02-17 08:19:21 +03:00
Egor 330d670f3f Merge pull request #2621 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.1
2026-02-17 07:55:52 +03:00
github-actions[bot] 41cb122a2f chore(main): release 3.15.1 2026-02-17 04:54:40 +00:00
Egor 1b3e6f2f11 Merge pull request #2620 from BEDOLAGA-DEV/dev
fix: add naive datetime guards to fromisoformat() in Redis cache readers
2026-02-17 07:54:20 +03:00
Fringg 6fa49485d9 fix: add naive datetime guards to fromisoformat() in Redis cache readers
Old Redis entries saved before utcnow→now(UTC) migration lack timezone
info, causing TypeError on subtraction with aware datetimes.
2026-02-17 07:52:26 +03:00
Egor 71aa023133 Merge pull request #2619 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.15.0
2026-02-17 07:25:32 +03:00
github-actions[bot] e567c02658 chore(main): release 3.15.0 2026-02-17 04:01:31 +00:00
Egor f393dc0840 Merge pull request #2618 from BEDOLAGA-DEV/dev
Dev
2026-02-17 07:01:07 +03:00
Fringg 5dc4b0ec15 chore: ruff format oauth.py, auth schemas, admin_notification_service 2026-02-17 06:57:30 +03:00
Fringg e68760cc66 fix: remove local UTC re-imports shadowing module-level import in purchase.py
Caused UnboundLocalError on datetime.now(UTC) at line 209 because
Python treats the function-local `from datetime import UTC` (lines 351, 362)
as a local variable declaration, making UTC unbound before those lines.
2026-02-17 06:45:46 +03:00
Fringg d9552799c1 feat: add web campaign links with bonus processing in auth flow
- Add web_link generation for campaigns (uses MINIAPP_CUSTOM_URL)
- Process campaign_slug in all auth endpoints (telegram, widget, email, oauth)
- Apply campaign bonus (balance/subscription/tariff) with SELECT FOR UPDATE lock
- Add rollback + user refresh on campaign bonus failure
- Fix N+1 query in campaign registrations (batch subscription check)
- Remove duplicate queries in get_campaign_statistics (~60 lines dead code)
- Simplify _store_refresh_token (remove TOCTOU pre-check, keep IntegrityError)
- Remove dead expression in campaign_service.py
- Align start_parameter max_length to 64 (matches DB column)
- Remove unused campaign_slug from EmailRegisterStandaloneRequest
2026-02-17 06:44:03 +03:00
Fringg c75ec0b22a fix: AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) 2026-02-17 05:23:49 +03:00
Fringg 27309f53d9 feat: add LOG_COLORS env setting to toggle console ANSI colors 2026-02-17 05:15:03 +03:00
106 changed files with 4021 additions and 9608 deletions
+6 -1
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.14.1"
".": "3.16.2"
}
+83
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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,
+86 -8
View File
@@ -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)
+582
View File
@@ -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}
+302
View File
@@ -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}
+91 -9
View File
@@ -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
+18 -5
View File
@@ -24,7 +24,7 @@ from ..auth.oauth_providers import (
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _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)
+162
View File
@@ -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,
)
+21 -5
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import 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,
)
+8 -13
View File
@@ -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:
+166
View File
@@ -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}
+23
View File
@@ -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):
+1
View File
@@ -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
+21 -2
View File
@@ -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."""
+162
View File
@@ -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)
+2
View File
@@ -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
+1
View File
@@ -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
+129
View File
@@ -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)
+253 -5
View File
@@ -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
# ============================================================================
+4
View File
@@ -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'
+2 -2
View File
@@ -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',
]
+5 -60
View File
@@ -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 -3
View File
@@ -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 -3
View File
@@ -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))
+19 -2
View File
@@ -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)
+38 -31
View File
@@ -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,
-1
View File
@@ -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
View File
@@ -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 (для мониторинга)
# ============================================================================
+71
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45 -17
View File
@@ -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 -3
View File
@@ -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
+1 -2
View File
@@ -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(
+16 -17
View File
@@ -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 -3
View File
@@ -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)
-2
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
-6
View File
@@ -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
+3 -11
View File
@@ -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)
-2
View File
@@ -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
View File
@@ -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),
],
)
+110 -6
View File
@@ -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,
+123 -24
View File
@@ -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()
-2
View File
@@ -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,
+8 -2
View File
@@ -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)
-2
View File
@@ -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
+106 -3
View File
@@ -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,
+235
View File
@@ -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()
+20 -19
View File
@@ -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(
+4 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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=[
+1 -1
View File
@@ -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=[
+1 -1
View File
@@ -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 -4
View File
@@ -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):
+5 -1
View File
@@ -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(
'💰 Начислен бонус рефереру ₽',
+14 -2
View File
@@ -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}'
+74 -54
View File
@@ -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 (
+1 -1
View File
@@ -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__)
+8 -2
View File
@@ -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)
+1 -2
View File
@@ -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):
+99 -8
View File
@@ -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,
+7
View File
@@ -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',
+51
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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,
+1 -2
View File
@@ -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)
+1
View File
@@ -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
+2 -2
View File
@@ -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
+4 -5
View File
@@ -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` — бот выполнит ту же процедуру автоматически.
+24 -22
View File
@@ -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(
'Синхронизация тарифов из конфига',
'💰',
-41
View File
@@ -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
+18 -7
View File
@@ -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()
+26
View File
@@ -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