From 80efd4ab86fcb7bacfcec5ea8fb3c7e0a96ccf6c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 25 Sep 2025 13:03:31 +0300 Subject: [PATCH] Add addon discount toggle for promo groups --- app/database/crud/promo_group.py | 8 ++- app/database/models.py | 1 + app/database/universal_migration.py | 51 +++++++++++++++++++ app/handlers/admin/promo_groups.py | 76 ++++++++++++++++++++++++++++ app/localization/locales/en.json | 6 +++ app/localization/locales/ru.json | 6 +++ app/services/subscription_service.py | 26 ++++++++-- locales/en.json | 6 +++ locales/ru.json | 6 +++ 9 files changed, 182 insertions(+), 4 deletions(-) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 3bc093f2..9296dd48 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -60,6 +60,7 @@ async def create_promo_group( device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: bool = True, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) @@ -76,6 +77,7 @@ async def create_promo_group( device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks, + apply_discounts_to_addons=bool(apply_discounts_to_addons), is_default=False, ) @@ -84,13 +86,14 @@ async def create_promo_group( await db.refresh(promo_group) logger.info( - "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽", + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s", promo_group.name, promo_group.server_discount_percent, promo_group.traffic_discount_percent, promo_group.device_discount_percent, normalized_period_discounts, (auto_assign_total_spent_kopeks or 0) / 100, + "on" if promo_group.apply_discounts_to_addons else "off", ) return promo_group @@ -106,6 +109,7 @@ async def update_promo_group( device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, auto_assign_total_spent_kopeks: Optional[int] = None, + apply_discounts_to_addons: Optional[bool] = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -120,6 +124,8 @@ async def update_promo_group( group.period_discounts = normalized_period_discounts or None if auto_assign_total_spent_kopeks is not None: group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks) + if apply_discounts_to_addons is not None: + group.apply_discounts_to_addons = bool(apply_discounts_to_addons) await db.commit() await db.refresh(group) diff --git a/app/database/models.py b/app/database/models.py index 0a3ad865..278178ff 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -292,6 +292,7 @@ class PromoGroup(Base): device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None) + apply_discounts_to_addons = Column(Boolean, nullable=False, default=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index b123c750..8f5648c5 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -931,6 +931,54 @@ async def ensure_promo_groups_setup(): "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" ) + addon_discount_column_exists = await check_column_exists( + "promo_groups", "apply_discounts_to_addons" + ) + + if not addon_discount_column_exists: + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1" + ) + ) + await conn.execute( + text( + "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL" + ) + ) + else: + logger.error( + f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}" + ) + return False + + logger.info( + "Добавлена колонка promo_groups.apply_discounts_to_addons" + ) + column_exists = await check_column_exists("users", "promo_group_id") if not column_exists: @@ -1994,6 +2042,7 @@ async def check_migration_status(): "users_promo_group_column": False, "promo_groups_period_discounts_column": False, "promo_groups_auto_assign_column": False, + "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "subscription_crypto_link_column": False, } @@ -2011,6 +2060,7 @@ async def check_migration_status(): status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') + status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') @@ -2048,6 +2098,7 @@ async def check_migration_status(): "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", "promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп", + "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", } diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 917f673f..d21b9249 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -39,6 +39,32 @@ def _format_discount_line(texts, group) -> str: ) +def _format_addon_discounts_line(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", + "Скидки на доп. услуги: включены", + ) + return texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", + "Скидки на доп. услуги: отключены", + ) + + +def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: + enabled = getattr(group, "apply_discounts_to_addons", True) + if enabled: + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", + "🧩 Отключить скидки на доп. услуги", + ) + return texts.t( + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", + "🧩 Включить скидки на доп. услуги", + ) + + def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: if not raw or not isinstance(raw, dict): return {} @@ -257,6 +283,7 @@ def _build_edit_menu_content( lines = [ header, _format_discount_line(texts, group), + _format_addon_discounts_line(texts, group), _format_auto_assign_line(texts, group), ] @@ -318,6 +345,12 @@ def _build_edit_menu_content( callback_data=f"promo_group_edit_field_{group.id}_periods", ) ], + [ + types.InlineKeyboardButton( + text=_get_addon_discounts_button_text(texts, group), + callback_data=f"promo_group_toggle_addons_{group.id}", + ) + ], [ types.InlineKeyboardButton( text=texts.t( @@ -1192,6 +1225,45 @@ async def delete_promo_group_confirmed( await callback.answer() +@admin_required +@error_handler +async def toggle_promo_group_addon_discounts( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + group = await _get_group_or_alert(callback, db) + if not group: + return + + texts = get_texts(db_user.language) + + new_value = not getattr(group, "apply_discounts_to_addons", True) + + group = await update_promo_group( + db, + group, + apply_discounts_to_addons=new_value, + ) + + status_text = texts.t( + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" + if new_value + else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", + "Скидки на докупку доп. услуг {status}.", + ).format(status="включены" if new_value else "отключены") + + await _send_edit_menu_after_update( + callback.message, + texts, + group, + db_user.language, + status_text, + ) + + await callback.answer() + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups") dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_")) @@ -1200,6 +1272,10 @@ def register_handlers(dp: Dispatcher): prompt_edit_promo_group_field, F.data.startswith("promo_group_edit_field_"), ) + dp.callback_query.register( + toggle_promo_group_addon_discounts, + F.data.startswith("promo_group_toggle_addons_"), + ) dp.callback_query.register( start_edit_promo_group, F.data.regexp(r"^promo_group_edit_\d+$"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 98b41a5b..e2aa4e2a 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -138,6 +138,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 831a55d1..669987a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -15,6 +15,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 190a9470..54ba6720 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -38,6 +38,26 @@ def _resolve_discount_percent( return 0 + +def _resolve_addon_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + group = promo_group or (getattr(user, "promo_group", None) if user else None) + + if group is not None and not getattr(group, "apply_discounts_to_addons", True): + return 0 + + return _resolve_discount_percent( + user, + promo_group, + category, + period_days=period_days, + ) + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -858,7 +878,7 @@ class SubscriptionService: if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_discount_percent = _resolve_discount_percent( + traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, "traffic", @@ -881,7 +901,7 @@ class SubscriptionService: if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _resolve_discount_percent( + devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, "devices", @@ -908,7 +928,7 @@ class SubscriptionService: server = await get_server_squad_by_id(db, server_id) if server and server.is_available: server_price_per_month = server.price_kopeks - servers_discount_percent = _resolve_discount_percent( + servers_discount_percent = _resolve_addon_discount_percent( user, promo_group, "servers", diff --git a/locales/en.json b/locales/en.json index f217ed28..110c9ae9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -151,6 +151,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups", "ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", diff --git a/locales/ru.json b/locales/ru.json index 2524c1d4..51eab11b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -17,6 +17,12 @@ "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.", + "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.", "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:", "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",