diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index 97f972e..b19bc0a 100644 --- a/backend/bot/app/web/admin_api_impl/broadcast.py +++ b/backend/bot/app/web/admin_api_impl/broadcast.py @@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: target = str(payload.get("target") or "all").strip().lower() if not text: return _error(400, "empty_text") - if target not in {"all", "active", "inactive"}: + if target not in {"all", "active", "inactive", "expired"}: target = "all" queue_manager = get_queue_manager() @@ -22,6 +22,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) + elif target == "expired": + user_ids = await user_dal.get_user_ids_with_expired_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 29ee2e2..8bd2ae8 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -16,6 +16,7 @@ import hashlib from html import escape as html_escape from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.orm import aliased from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches from bot.infra.redis import cache_delete_pattern, redis_key @@ -538,9 +539,34 @@ def _user_panel_status_condition(panel_status: str): normalized_status == "active", blank_status & Subscription.is_active.is_(True) ) elif status == "expired": - status_cond = or_( - normalized_status == "expired", blank_status & Subscription.is_active.is_(False) + now = datetime.now(timezone.utc) + expired_subs = aliased(Subscription) + active_subs = aliased(Subscription) + expired_status = sa_func.lower(sa_func.coalesce(expired_subs.status_from_panel, "")) + expired_blank_status = or_( + expired_subs.status_from_panel.is_(None), + expired_subs.status_from_panel == "", ) + expired_condition = or_( + expired_status == "expired", + expired_blank_status & expired_subs.is_active.is_(False), + expired_subs.end_date <= now, + ) + expired_exists = ( + select(expired_subs.subscription_id) + .where(expired_subs.user_id == User.user_id, expired_condition) + .exists() + ) + active_exists = ( + select(active_subs.subscription_id) + .where( + active_subs.user_id == User.user_id, + active_subs.is_active.is_(True), + active_subs.end_date > now, + ) + .exists() + ) + return and_(expired_exists, ~active_exists) else: status_cond = normalized_status == "limited" diff --git a/backend/bot/handlers/admin/broadcast.py b/backend/bot/handlers/admin/broadcast.py index 73b3f31..0e0754b 100644 --- a/backend/bot/handlers/admin/broadcast.py +++ b/backend/bot/handlers/admin/broadcast.py @@ -155,7 +155,7 @@ async def change_broadcast_target_handler( return new_target = callback.data.split(":")[1] - if new_target not in {"all", "active", "inactive"}: + if new_target not in {"all", "active", "inactive", "expired"}: await callback.answer("Unknown target.", show_alert=True) return @@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler( user_ids = await user_dal.get_user_ids_with_active_subscription(session) elif target == "inactive": user_ids = await user_dal.get_user_ids_without_active_subscription(session) + elif target == "expired": + user_ids = await user_dal.get_user_ids_with_expired_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) diff --git a/backend/bot/keyboards/inline/admin_keyboards.py b/backend/bot/keyboards/inline/admin_keyboards.py index 2b9530d..fdfd419 100644 --- a/backend/bot/keyboards/inline/admin_keyboards.py +++ b/backend/bot/keyboards/inline/admin_keyboards.py @@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard( _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - # Row: target selection (all / active / inactive) + # Row: target selection (all / active / inactive / expired) target_all_label = _(key="broadcast_target_all_button") target_active_label = _(key="broadcast_target_active_button") target_inactive_label = _(key="broadcast_target_inactive_button") + target_expired_label = _(key="broadcast_target_expired_button") # Highlight current selection with a prefix def mark_selected(label: str, is_selected: bool) -> str: @@ -473,7 +474,10 @@ def get_broadcast_confirmation_keyboard( text=mark_selected(target_inactive_label, target == "inactive"), callback_data="broadcast_target:inactive", ) - builder.adjust(3) + builder.button( + text=mark_selected(target_expired_label, target == "expired"), + callback_data="broadcast_target:expired", + ) # Row: confirmation builder.button( @@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard( builder.button( text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel" ) - builder.adjust(2) + builder.adjust(2, 2, 2) return builder.as_markup() diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index cc2102e..4d0308b 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -777,6 +777,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: free_subscription_users = int(subscription_counts[3] or 0) inactive_users = total_users - active_subscription_users + expired_subscription_users = await count_users_with_expired_subscription(session) return { "total_users": total_users, @@ -787,6 +788,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]: "trial_users": trial_users, "free_subscription_users": free_subscription_users, "inactive_users": max(0, inactive_users), + "expired_subscription_users": expired_subscription_users, "referral_users": referral_users, } @@ -841,6 +843,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +def _expired_subscription_exists_for_user(now: datetime): + expired_subs = aliased(Subscription) + normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, "")) + blank_status = or_( + expired_subs.status_from_panel.is_(None), + expired_subs.status_from_panel == "", + ) + expired_condition = or_( + normalized_status == "expired", + blank_status & expired_subs.is_active.is_(False), + expired_subs.end_date <= now, + ) + + return ( + select(expired_subs.subscription_id) + .where(expired_subs.user_id == User.user_id, expired_condition) + .exists() + ) + + +def _active_subscription_exists_for_user(now: datetime): + active_subs = aliased(Subscription) + return ( + select(active_subs.subscription_id) + .where( + active_subs.user_id == User.user_id, + active_subs.is_active == True, + active_subs.end_date > now, + ) + .exists() + ) + + +async def count_users_with_expired_subscription(session: AsyncSession) -> int: + """Count users who have an expired subscription and no currently active subscription.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + stmt = select(func.count(User.user_id)).where( + _expired_subscription_exists_for_user(now), + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs with an expired subscription and no active one.""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + stmt = select(User.user_id).where( + User.is_banned == False, + _expired_subscription_exists_for_user(now), + ~_active_subscription_exists_for_user(now), + ) + result = await session.execute(stmt) + return result.scalars().all() + + async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool: """Completely remove a user and all dependent records from the database. diff --git a/frontend/src/admin/sections/StatsSection.svelte b/frontend/src/admin/sections/StatsSection.svelte index ed9d30e..353a717 100644 --- a/frontend/src/admin/sections/StatsSection.svelte +++ b/frontend/src/admin/sections/StatsSection.svelte @@ -728,7 +728,11 @@ diff --git a/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js index 33fea5a..7e7e208 100644 --- a/frontend/src/lib/admin/stores/broadcastStore.js +++ b/frontend/src/lib/admin/stores/broadcastStore.js @@ -12,6 +12,7 @@ export function createBroadcastStore({ api, onToast, at }) { { value: "all", label: at("broadcast_target_all", {}, "Все активные") }, { value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, + { value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") }, ]; async function runBroadcast() { diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index d24ddb4..627b6e8 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -82665,6 +82665,7 @@ export const DEMO_DATASET = { paid_subscriptions: 177, trial_users: 0, inactive_users: 193, + expired_subscription_users: 97, referral_users: 106, }, financial: { diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 058d813..8f0c816 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1278,6 +1278,7 @@ export async function mockApi(path, options = {}, context = {}) { trial_users: 8, free_subscription_users: 23, inactive_users: 76, + expired_subscription_users: 31, banned_users: 3, referral_users: 34, }, diff --git a/locales/en.json b/locales/en.json index ab61a77..b37f5f3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -179,6 +179,7 @@ "broadcast_target_all_button": "👥 All", "broadcast_target_active_button": "✅ Active", "broadcast_target_inactive_button": "⌛ Inactive", + "broadcast_target_expired_button": "⏰ Expired", "confirm_broadcast_send_button": "✅ Send", "admin_broadcast_sending_started": "Starting broadcast...", "admin_broadcast_error_no_message": "Error: no message to broadcast.", @@ -1127,6 +1128,7 @@ "admin_broadcast_target_all": "All active", "admin_broadcast_target_active": "With subscription", "admin_broadcast_target_inactive": "No subscription", + "admin_broadcast_target_expired": "Expired subscription", "admin_expired_at": "Expired {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Failed to load statistics: {error}", @@ -1154,6 +1156,7 @@ "admin_stats_trend_referrals": "Referrals: {count}", "admin_stats_label_inactive": "No active subscription", "admin_stats_trend_new_today": "Registrations today: {count}", + "admin_stats_trend_expired_subscriptions": "Expired subscriptions: {count}", "admin_stats_section_revenue": "Revenue", "admin_stats_section_revenue_hint": "Succeeded payments, shop currency", "admin_stats_revenue_chart_title": "Daily revenue (UTC)", diff --git a/locales/ru.json b/locales/ru.json index b4d5224..cdc1f9e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -179,6 +179,7 @@ "broadcast_target_all_button": "👥 Все", "broadcast_target_active_button": "✅ Активные", "broadcast_target_inactive_button": "⌛ Неактивные", + "broadcast_target_expired_button": "⏰ Просроченные", "confirm_broadcast_send_button": "✅ Отправить", "admin_broadcast_sending_started": "Начинаю рассылку...", "admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.", @@ -1127,6 +1128,7 @@ "admin_broadcast_target_all": "Все активные", "admin_broadcast_target_active": "С подпиской", "admin_broadcast_target_inactive": "Без подписки", + "admin_broadcast_target_expired": "С просроченной подпиской", "admin_expired_at": "Истекла {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Не удалось загрузить статистику: {error}", @@ -1154,6 +1156,7 @@ "admin_stats_trend_referrals": "Рефералы: {count}", "admin_stats_label_inactive": "Без активной подписки", "admin_stats_trend_new_today": "Регистраций сегодня: {count}", + "admin_stats_trend_expired_subscriptions": "С просроченной подпиской: {count}", "admin_stats_section_revenue": "Доходы", "admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина", "admin_stats_revenue_chart_title": "Выручка по дням (UTC)", diff --git a/tests/test_admin_panel_stats_cache.py b/tests/test_admin_panel_stats_cache.py index 74ed0ef..d5eb82c 100644 --- a/tests/test_admin_panel_stats_cache.py +++ b/tests/test_admin_panel_stats_cache.py @@ -101,6 +101,7 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase): "trial_users": 1, "free_subscription_users": 0, "inactive_users": 2, + "expired_subscription_users": 1, "referral_users": 3, } ) diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index 873e587..ede5e3f 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -41,6 +41,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): side_effect=[ FakeResult((10, 1, 2, 3)), FakeResult((8, 4, 2, 2)), + FakeResult(3), ] ) ) @@ -58,6 +59,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase): "trial_users": 2, "free_subscription_users": 2, "inactive_users": 2, + "expired_subscription_users": 3, "referral_users": 3, }, ) @@ -211,6 +213,42 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): self.assertIn("LEFT OUTER JOIN", sql) self.assertIn("IS NULL", sql) + async def test_count_users_with_expired_subscription_excludes_currently_active(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(4))) + + result = await user_dal.count_users_with_expired_subscription(session) + + self.assertEqual(result, 4) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("EXISTS", sql) + self.assertIn("EXPIRED", sql) + self.assertIn("END_DATE <=", sql) + self.assertIn("NOT (EXISTS", sql) + self.assertNotIn("USERS.IS_BANNED", sql) + + async def test_get_user_ids_with_expired_subscription_excludes_banned_users(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult([2, 3]))) + + result = await user_dal.get_user_ids_with_expired_subscription(session) + + self.assertEqual(result, [2, 3]) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("USERS.IS_BANNED = FALSE", sql) + self.assertIn("EXPIRED", sql) + self.assertIn("NOT (EXISTS", sql) + async def test_merge_users_uses_bulk_updates_for_related_tables(self): source = SimpleNamespace( user_id=1,