fix: multi-tariff Stage 4 critical fixes — keyboards, guest purchase, monitoring, tariff deletion
- inline.py: open_subscription_link/subscription_connect callbacks now include
:{subscription_id} suffix in multi-tariff mode. Main menu uses subscription_connect
(picker) instead of bare open_subscription_link.
- guest_purchase_service: activate_purchase non-tariff path uses proper ordering
(non-daily, max days_left) instead of arbitrary _active[0]
- monitoring_service: _send_expired_day1_notification and discount notification
keyboards use se:{subscription.id} in multi-tariff (2 more hardcoded callbacks fixed)
- admin/tariffs: delete_tariff_confirmed now checks active subscription count
before deletion (RESTRICT FK). Prompt shows blocking message when active subs exist.
New CRUD function get_active_subscriptions_count_by_tariff_id.
This commit is contained in:
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, Subscription, Tariff
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -393,7 +393,8 @@ async def update_tariff(
|
||||
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
"""
|
||||
Удаляет тариф.
|
||||
Подписки с этим тарифом получат tariff_id = NULL.
|
||||
FK с ondelete=RESTRICT — удаление невозможно, если есть привязанные подписки.
|
||||
Вызывающий код должен проверить отсутствие активных подписок до вызова.
|
||||
"""
|
||||
tariff_id = tariff.id
|
||||
tariff_name = tariff.name
|
||||
@@ -404,7 +405,7 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
)
|
||||
affected_subscriptions = subscriptions_count.scalar_one()
|
||||
|
||||
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
|
||||
# Удаляем тариф (FK RESTRICT — подписок с tariff_id быть не должно)
|
||||
await db.delete(tariff)
|
||||
await db.commit()
|
||||
|
||||
@@ -424,6 +425,18 @@ async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> in
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_active_subscriptions_count_by_tariff_id(db: AsyncSession, tariff_id: int) -> int:
|
||||
"""Подсчитывает количество активных (active/trial) подписок на тарифе."""
|
||||
active_statuses = [SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_(active_statuses),
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def set_tariff_promo_groups(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
delete_tariff,
|
||||
get_active_subscriptions_count_by_tariff_id,
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
get_tariffs_with_subscriptions_count,
|
||||
@@ -2158,11 +2159,34 @@ async def confirm_delete_tariff(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff_id)
|
||||
|
||||
if active_count > 0:
|
||||
total_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
await callback.message.edit_text(
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\n'
|
||||
f'Невозможно удалить тариф <b>{html.escape(tariff.name)}</b>.\n\n'
|
||||
f'⚠️ <b>Активных подписок:</b> {active_count} (всего: {total_count})\n'
|
||||
f'Сначала деактивируйте тариф и дождитесь окончания всех активных подписок, '
|
||||
f'либо переведите подписки на другой тариф.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text='◀️ Назад к тарифу', callback_data=f'admin_tariff_view:{tariff_id}')],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
|
||||
warning = ''
|
||||
if subs_count > 0:
|
||||
warning = f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
|
||||
warning = (
|
||||
f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} неактивных подписок.\n'
|
||||
f'Они потеряют привязку к тарифу.'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{warning}',
|
||||
@@ -2197,6 +2221,15 @@ async def delete_tariff_confirmed(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
# Защита от удаления тарифа с активными подписками (FK RESTRICT)
|
||||
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff.id)
|
||||
if active_count > 0:
|
||||
await callback.answer(
|
||||
f'Невозможно удалить тариф: {active_count} активных подписок. Сначала деактивируйте тариф.',
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
tariff_name = tariff.name
|
||||
await delete_tariff(db, tariff)
|
||||
|
||||
|
||||
+29
-8
@@ -638,7 +638,11 @@ def get_main_menu_keyboard(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
callback_data=(
|
||||
'subscription_connect'
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else 'open_subscription_link'
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1066,6 +1070,13 @@ def get_subscription_keyboard(
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Sub ID suffix for multi-tariff callback routing
|
||||
_sub_suffix = (
|
||||
f':{subscription.id}'
|
||||
if settings.is_multi_tariff_enabled() and subscription and hasattr(subscription, 'id')
|
||||
else ''
|
||||
)
|
||||
|
||||
if has_subscription:
|
||||
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
||||
if subscription_link:
|
||||
@@ -1094,7 +1105,8 @@ def get_subscription_keyboard(
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data=f'subscription_connect{_sub_suffix}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1107,7 +1119,7 @@ def get_subscription_keyboard(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
callback_data=f'open_subscription_link{_sub_suffix}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1115,7 +1127,8 @@ def get_subscription_keyboard(
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data=f'subscription_connect{_sub_suffix}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -1132,7 +1145,8 @@ def get_subscription_keyboard(
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data=f'subscription_connect{_sub_suffix}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2539,11 +2553,12 @@ def get_device_selection_keyboard(
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
_osl_cb = f'open_subscription_link:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'open_subscription_link'
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SHOW_SUBSCRIPTION_LINK', '📋 Показать ссылку подписки'),
|
||||
callback_data='open_subscription_link',
|
||||
callback_data=_osl_cb,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2610,11 +2625,16 @@ def get_connection_guide_keyboard(
|
||||
]
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
_osl_cb = (
|
||||
f'open_subscription_link:{sub_id}'
|
||||
if sub_id and settings.is_multi_tariff_enabled()
|
||||
else 'open_subscription_link'
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
callback_data=_osl_cb,
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
@@ -2651,12 +2671,13 @@ def get_connection_guide_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
_sc_cb = f'subscription_connect:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'subscription_connect'
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'),
|
||||
callback_data='subscription_connect',
|
||||
callback_data=_sc_cb,
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data=back_cb)],
|
||||
|
||||
@@ -1060,7 +1060,13 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
_active = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
existing_subscription = _active[0] if _active else None
|
||||
if _active:
|
||||
# Prefer non-daily tariffs; among those pick the one with most days_left
|
||||
_non_daily = [s for s in _active if not getattr(s, 'is_daily_tariff', False)]
|
||||
_pool = _non_daily or _active
|
||||
existing_subscription = max(_pool, key=lambda s: s.days_left)
|
||||
else:
|
||||
existing_subscription = None
|
||||
else:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription is not None:
|
||||
|
||||
@@ -1606,12 +1606,18 @@ class MonitoringService:
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
extend_callback = (
|
||||
f'se:{subscription.id}'
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else 'subscription_extend'
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
|
||||
callback_data='subscription_extend',
|
||||
callback_data=extend_callback,
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -1696,6 +1702,12 @@ class MonitoringService:
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
extend_callback = (
|
||||
f'se:{subscription.id}'
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else 'subscription_extend'
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
@@ -1706,7 +1718,7 @@ class MonitoringService:
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
|
||||
callback_data='subscription_extend',
|
||||
callback_data=extend_callback,
|
||||
)
|
||||
],
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user