diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index b19bc0a..7daae95 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", "expired"}: + if target not in {"all", "active", "inactive", "expired", "never"}: target = "all" queue_manager = get_queue_manager() @@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: 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) + elif target == "never": + user_ids = await user_dal.get_user_ids_without_any_subscription(session) else: user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) @@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response: ) return _ok({"queued": sent, "failed": failed, "target": target}) + + +async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response: + """Return how many users each broadcast audience currently resolves to.""" + _require_admin_user_id(request) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + counts = { + "all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)), + "active": len(await user_dal.get_user_ids_with_active_subscription(session)), + "inactive": len(await user_dal.get_user_ids_without_active_subscription(session)), + "expired": len(await user_dal.get_user_ids_with_expired_subscription(session)), + "never": len(await user_dal.get_user_ids_without_any_subscription(session)), + } + + return _ok({"counts": counts}) diff --git a/backend/bot/app/web/admin_api_impl/routes.py b/backend/bot/app/web/admin_api_impl/routes.py index 6b0a10c..a7e418c 100644 --- a/backend/bot/app/web/admin_api_impl/routes.py +++ b/backend/bot/app/web/admin_api_impl/routes.py @@ -57,6 +57,7 @@ def setup_admin_routes(app: web.Application) -> None: router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route) router.add_get("/api/admin/support/stats", admin_support_stats_route) + router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route) router.add_post("/api/admin/broadcast", admin_broadcast_route) router.add_post("/api/admin/sync", admin_sync_route) diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 6a3c1af..bc58eef 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -857,6 +857,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis return result.scalars().all() +async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]: + """Return non-banned user IDs who never had any subscription or trial. + + These are users who registered but have no ``Subscription`` rows at all — + no active, no expired and no trial history. In other words, accounts that + signed up and never did anything. + """ + any_sub = aliased(Subscription) + + stmt = ( + select(User.user_id) + .outerjoin(any_sub, any_sub.user_id == User.user_id) + .where( + and_( + User.is_banned == False, + any_sub.user_id.is_(None), + ) + ) + ) + result = await session.execute(stmt) + 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, "")) diff --git a/frontend/src/admin/sections/BroadcastSection.svelte b/frontend/src/admin/sections/BroadcastSection.svelte index 5c6c79d..849bac0 100644 --- a/frontend/src/admin/sections/BroadcastSection.svelte +++ b/frontend/src/admin/sections/BroadcastSection.svelte @@ -1,16 +1,27 @@
@@ -24,7 +35,7 @@ {at("broadcast_label_audience", {}, "Аудитория")} broadcastStore.updateField({ broadcastTarget: value })} /> diff --git a/frontend/src/lib/admin/stores/broadcastStore.js b/frontend/src/lib/admin/stores/broadcastStore.js index 7e7e208..3a1af9f 100644 --- a/frontend/src/lib/admin/stores/broadcastStore.js +++ b/frontend/src/lib/admin/stores/broadcastStore.js @@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) { broadcastText: "", broadcastBusy: false, broadcastResult: null, + broadcastCounts: null, }); const BROADCAST_TARGET_OPTIONS = [ @@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) { { value: "active", label: at("broadcast_target_active", {}, "С подпиской") }, { value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") }, { value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") }, + { + value: "never", + label: at("broadcast_target_never", {}, "Без подписки и без истории"), + }, ]; + async function loadCounts() { + try { + const res = await api("/admin/broadcast/audience-counts"); + if (res?.ok && res.counts) { + state.update((s) => ({ ...s, broadcastCounts: res.counts })); + } + } catch { + // Counts are advisory; ignore failures and keep plain labels. + } + } + async function runBroadcast() { let text = ""; let target = ""; @@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) { update: state.update, runBroadcast, updateField, + loadCounts, BROADCAST_TARGET_OPTIONS, }; } diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index a2a279f..cfbdf45 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -658,6 +658,12 @@ function demoApiResponse(path, cleanPath, options, context) { const params = queryParams(path); if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats); + if (cleanPath === "/admin/broadcast/audience-counts") { + return { + ok: true, + counts: { all: 1280, active: 742, inactive: 538, expired: 311, never: 227 }, + }; + } if (cleanPath === "/admin/sync") return { ok: true, status: "queued" }; if (cleanPath === "/admin/payments") { diff --git a/locales/en.json b/locales/en.json index 4929475..cf3ffff 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1143,6 +1143,7 @@ "admin_broadcast_target_active": "With subscription", "admin_broadcast_target_inactive": "No subscription", "admin_broadcast_target_expired": "Expired subscription", + "admin_broadcast_target_never": "No subscription, no history", "admin_expired_at": "Expired {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Failed to load statistics: {error}", diff --git a/locales/ru.json b/locales/ru.json index 3e21e15..4ce0660 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1143,6 +1143,7 @@ "admin_broadcast_target_active": "С подпиской", "admin_broadcast_target_inactive": "Без подписки", "admin_broadcast_target_expired": "С просроченной подпиской", + "admin_broadcast_target_never": "Без подписки и без истории", "admin_expired_at": "Истекла {date}", "admin_expired_badge": "Expired {date}", "admin_stats_error": "Не удалось загрузить статистику: {error}", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index 80418fd..f1378ac 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -183,6 +183,7 @@ class WebAppRouteContractTests(unittest.TestCase): ("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route", ("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route", ("GET", "/api/admin/logs"): "admin_logs_route", + ("GET", "/api/admin/broadcast/audience-counts"): "admin_broadcast_audience_counts_route", ("POST", "/api/admin/broadcast"): "admin_broadcast_route", ("POST", "/api/admin/sync"): "admin_sync_route", ("GET", "/api/admin/ads"): "admin_ads_list_route",