- Add get_subscription_request_history to RemnaWave API client
(GET /api/users/{uuid}/subscription-request-history with pagination)
- Add GET /admin/users/{user_id}/subscription-request-history endpoint
with subscription_id param for multi-tariff support
1. _check_expired_subscription_followups: added Subscription.status=EXPIRED
filter (was matching ALL statuses including ACTIVE), User.status=ACTIVE
filter, and 30-day lookback window to stop scanning ancient subscriptions
2. _get_expiring_paid_subscriptions: added User.status=ACTIVE filter to
prevent sending "expiring" notifications to blocked/deleted users
3. Multi-tariff: before sending expired/followup notifications, check if
user has another ACTIVE subscription with end_date > now — skip if they
still have service through another tariff
4. Multi-tariff: same check for _check_expired_subscriptions — don't send
"subscription expired" if user has another active sub
- Fix 7 intermediate error paths (balance deduction failures) that used
callback.answer() after the early answer was already consumed — user
got no error feedback at all
- Fix 2 unfixed handlers: confirm_tariff_purchase, confirm_daily_tariff_purchase
— same early-answer pattern applied
- All 7 purchase/extend/switch handlers now consistently use early
callback.answer() + edit_text for errors
Telegram invalidates callback queries after 30 seconds. When the bot
performed panel sync, DB transactions, and admin notifications before
answering, callback.answer() threw TelegramBadRequest: query is too old.
Moved callback.answer() to immediately after guard checks (balance,
tariff availability) in 5 handlers:
- confirm_tariff_extend
- confirm_custom_tariff_purchase
- confirm_tariff_switch
- confirm_daily_tariff_switch
- confirm_instant_switch
Error feedback now uses callback.message.edit_text() instead of the
expired callback.answer().
The "Unpin all" button called deactivate_active_pinned_message() first,
then looped over users to unpin. If Telegram API calls failed or timed
out, the message was already marked inactive in the DB with no way to
retry. Now: get active message → unpin from all chats → deactivate in DB.
Restore integration hooks dropped in PR #2851 merge:
- PurchaseRequest accepts yandex_cid, referrer, subid from frontend
- Cache yandex_cid and subid in Redis at purchase creation (24h TTL)
- On fulfill_purchase: extract subid from cache, persist to DB
- Save Yandex CID from Redis to yandex_client_id_map
- Fire on_registration + S2S postback for new accounts
- Fire on_purchase + S2S postback for all paid purchases
- All hooks wrapped in try/except — failures never block delivery
Two bugs caused max promo group assignment on gift send/activate:
1. Buyer: GIFT_PAYMENT was counted in get_user_total_spent_kopeks
alongside SUBSCRIPTION_PAYMENT. Now only SUBSCRIPTION_PAYMENT
counts as personal spending for promo group auto-assignment.
2. Recipient: fulfill_purchase and activate_purchase created a
SUBSCRIPTION_PAYMENT transaction for the recipient with the full
gift price. Now skipped for gift recipients — they didn't pay.
Replace str(exc) with generic 'Action failed: internal error' in both
_execute_for_user and _execute_for_subscription catch-all blocks.
Prevents leaking internal paths, SQL details, or connection strings.
- Thread admin_id through _execute_for_user to _do_delete_user for audit trail
- Replace raw exception str(e) with generic error message in client response
- Add subscriptions=[] to failure paths to prevent MissingGreenlet
- Add campaign_id and partner_id query params to GET /admin/users
- Filter users by advertising campaign via EXISTS subquery on registrations
- Filter users by partner via JOIN campaign registrations → campaigns
- Add DELETE_USER bulk action type with delete_from_panel param
- Handler calls UserService.delete_user_account for full bot+panel removal
- Permission check: users:delete required for delete_user action
- Add to _USER_LEVEL_ACTIONS (operates on user_ids, not subscription_ids)
- Add replaces_tab column to InfoPage ('faq','rules','privacy','offer')
- Migration 0067: add nullable replaces_tab column
- CRUD: clear_replaces_tab ensures one page per tab, get_tab_replacements
returns {tab: slug} mapping for active pages
- Admin routes: auto-clear old assignment on create/update
- Public route: GET /info-pages/tab-replacements (no auth)
- Schemas: replaces_tab with regex validation in all request/response models
- Add page_type column to InfoPage model ('page' or 'faq')
- Migration 0066: ALTER TABLE ADD COLUMN with server_default='page'
- Update schemas with page_type field and regex validation
- Update CRUD: page_type in create, filter in list
- Update admin/public routes with page_type query filter
- Backward compatible: existing pages default to type 'page'
- Remove triple-redundant slug index: keep only unique=True on column
(PostgreSQL creates unique index automatically), remove __table_args__
index and explicit create_index in migration
- Type ReorderRequest.items with ReorderItem(id: int, sort_order: int)
instead of raw dict — prevents unvalidated input causing 500
- Migration downgrade: just drop_table (unique constraint drops with it)
- InfoPage model: slug, title (JSONB locale dict), content (JSONB),
is_active, sort_order, icon, created_at/updated_at
- CRUD: create, get by id/slug, list, update, delete, reorder
- Admin routes: /admin/info-pages with full CRUD, toggle-active, reorder
(permissions: settings:read/settings:edit)
- Public routes: /info-pages list active, /info-pages/{slug} get by slug
- Migration 0065: create info_pages table with unique slug index
- Custom pages support: admins can create any info page with any slug
parse_mode='HTML' was missing from message sends during /start
registration. HTML tags like <a href="..."> were shown as literal
text instead of rendered links.
Fixed in 4 places:
- Privacy policy edit_text (line 1151)
- Privacy policy fallback answer (line 1158)
- Welcome/offer text in complete_registration_from_callback (line 1729)
- Welcome/offer text in complete_registration (line 2084)
tariff_id query param now accepts comma-separated IDs (e.g.
tariff_id=1,3,5). CRUD functions updated to use IN() operator
for multi-tariff server-side filtering. Pagination works correctly
with multiple tariffs selected.
Deactivates user in RemnaWave panel first, then deletes subscription
with related SubscriptionServer and TrafficPurchase records.
Subscription-level action (works with subscription_ids targeting).
- Add SET_DEVICES bulk action: sets device_limit on subscriptions,
syncs to RemnaWave panel (subscription-level action)
- Add device_limit to SubscriptionListItem and BulkSubscriptionInfo
schemas for frontend display
- Populate device_limit in _build_user_list_item and
_build_subscription_info helpers
subscriptions were only populated when is_multi_tariff_enabled()
was true. Users with multiple subscriptions in regular tariff mode
had empty subscriptions[] — bulk actions couldn't show or select them.
Now subscriptions are always populated regardless of tariff mode.
_execute_for_subscription accessed user.subscriptions after commit,
triggering async lazy load → MissingGreenlet. Actions committed
successfully but reported as failed with cryptic error message.
Fix: use _build_subscription_info([sub]) with the already-loaded
targeted subscription instead of trying to lazy-load the full
user.subscriptions list.
- Add subscription_ids to BulkExecuteRequest (mutually exclusive with
user_ids via model_validator). Admins can now target specific
subscriptions instead of auto-resolving the first active one.
- Add _execute_for_subscription dispatcher that loads subscription by
ID, gets user, and passes sub_override to action handlers
- Add sub_override parameter to all 5 subscription-level handlers
(extend, cancel, activate, change_tariff, add_traffic) — bypasses
_resolve_subscription when a specific subscription is targeted
- Add SubscriptionListItem to UserListItem response — in multi-tariff
mode, each user row includes all their subscriptions with tariff
name, status, days remaining, traffic info
- User-level actions (add_balance, assign_promo_group, grant) reject
subscription_ids with 400
- Add subscription_id field to BulkUserResult and SSE progress events
- Add _stream_bulk_execute_subscriptions SSE generator
- Backward compatible: existing user_ids requests work unchanged
The bulk actions page filters (subscription_status, tariff_id,
promo_group_id) were sent by the frontend but ignored by the
backend — the list_users endpoint had no such parameters.
- Add subscription_status, tariff_id, promo_group_id query params
to GET /cabinet/admin/users
- Add subscription-level filtering via subquery in get_users_list
and get_users_count CRUD functions
- Add tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb,
device_limit, days_remaining to UserListItem response schema
- Populate tariff info from subscription.tariff relationship in
_build_user_list_item
When switching to a tariff with empty/null allowed_squads, the old
tariff's squads were preserved on the subscription. Now always sets
connected_squads from the new tariff (or empty list).
- Add SSE streaming mode (?stream=true): per-user progress events with
real-time success/error counts, final summary event
- Add GRANT_SUBSCRIPTION action: creates new subscription with tariff,
skips users who already have that tariff (multi-tariff aware),
handles IntegrityError with graceful rollback
- Add BulkSubscriptionInfo: returns all user's subscriptions in each
result for multi-tariff visibility
- Refactor: extract _validate_and_prepare and _execute_for_user helpers
shared by streaming and non-streaming paths
- Add db.rollback() in per-user exception handler to prevent session
poisoning (one failed commit would abort all subsequent users)
- Add multi-tariff duplicate subscription check in activate_subscription
(prevents uq_subscriptions_user_tariff_active violation)
- Add multi-tariff duplicate subscription check in change_tariff
(prevents switching to a tariff the user already holds)
Add POST /cabinet/admin/bulk/execute endpoint for applying operations
to multiple users at once (up to 500 per request):
- extend_subscription / add_days: extend subscription by N days
- cancel_subscription: deactivate and expire subscriptions
- activate_subscription: reactivate expired subscriptions
- change_tariff: switch tariff without changing remaining days
- add_traffic: add extra GB to subscription traffic
- add_balance: credit balance with transaction record
- assign_promo_group: set or remove promo group for users
Features: dry_run preview mode, partial success handling (per-user
try/except), param validation before loop, auto-panel sync after
subscription mutations, multi-tariff mode support
On first startup with empty database, ensure_servers_synced() fetched
squads from RemnaWave but create_server_squad() failed with
ValueError('Server squad must be linked to at least one promo group')
because no default promo group existed yet.
Now _get_default_promo_group_id() auto-creates a default promo group
via _get_or_create_default_promo_group() when none exists, matching
the pattern used throughout the codebase for user registration.
RemnaWave API requires username >= 3 characters. Users with short
Telegram names (e.g. "Su") produced 2-char usernames that failed
validation, preventing subscription sync after payment.
Now format_remnawave_username pads short results with the user
identifier (telegram_id/email/user_id) to ensure minimum length.
The get_inactive_users query filtered only by last_activity (last bot
interaction), ignoring subscription end dates. Users who bought long
subscriptions (3-6-12 months) but didn't interact with the bot got
flagged as inactive and deleted+banned while their subscription was
still active or recently expired.
Fix: add SQL subquery excluding users who have ANY subscription with
end_date >= threshold_date. A user is now only deletable when BOTH
their last_activity AND their latest subscription end_date are older
than the configured inactivity period.
Previously the bot only responded "Не понимаю эту команду" to text
messages. Photos, videos, documents and stickers sent outside of an
active FSM state were silently ignored, causing users to think their
media was received when it wasn't (e.g. support ticket screenshots
sent as separate messages).
Now the bot replies with the same "use menu buttons" message for any
unhandled media when no FSM state is active.
Prevent enabling analytics_view/click without providing the
corresponding goal identifier, which would result in empty
Yandex Metrika calls on the frontend.
- Add if has_media else None guards in user-side ticket handlers
(create_ticket, add_message) matching admin handler pattern
- Fix Telegram notification using resolved primary_file_id/primary_type
instead of raw request fields for gallery messages
- Narrow except Exception to (TypeError, KeyError, ValueError) in
_message_to_response with warning log for debugging
- Add media_items parameter to TicketCRUD.create_ticket and
TicketCRUD.add_message for CRUD layer parity
- Add TicketMediaItemResponse and media_items field to webapi
TicketMessageResponse to prevent data loss on read
- Add media_items JSONB column to TicketMessage model for multi-media
gallery support (photos/videos/documents in one bubble)
- Add TicketMediaItem schema with type validation and shared
_validate_media_bundle helper (max 10 items, legacy field compat)
- Update admin and user ticket handlers to store media_items and
back-fill legacy media_type/media_file_id/media_caption from first
item for backward compatibility
- Update _message_to_response in both admin and user routes to include
media_items in API responses
- Allow empty message text when media is attached (message field now
defaults to empty string with model validator ensuring text or media)
- Add migration 0061 with idempotent column check
Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded
(hardcoded version strings would regress dynamic manifest reading)