feat(admin): add never-subscribed broadcast target with audience counts
Add a broadcast audience for users who registered but never had any subscription or trial (no Subscription rows at all), backed by a new get_user_ids_without_any_subscription DAL helper and a 'never' target in the webapp broadcast route. Add GET /api/admin/broadcast/audience-counts so the audience dropdown shows the recipient count next to each option, with graceful fallback when counts are unavailable.
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, ""))
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
<script>
|
||||
import { Textarea } from "$components/ui/index.js";
|
||||
import { Send } from "$components/ui/icons.js";
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
const broadcastStore = getContext("broadcastStore");
|
||||
|
||||
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
|
||||
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
|
||||
$broadcastStore);
|
||||
|
||||
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
||||
|
||||
// Append the resolved audience size to each option once counts are loaded.
|
||||
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
|
||||
const count = broadcastCounts?.[option.value];
|
||||
return count == null ? option : { ...option, label: `${option.label} (${count})` };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
broadcastStore.loadCounts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-card">
|
||||
@@ -24,7 +35,7 @@
|
||||
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
||||
<AdminSelect
|
||||
value={broadcastTarget}
|
||||
items={BROADCAST_TARGET_OPTIONS}
|
||||
items={targetOptions}
|
||||
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
|
||||
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user