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()
|
target = str(payload.get("target") or "all").strip().lower()
|
||||||
if not text:
|
if not text:
|
||||||
return _error(400, "empty_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"
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
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)
|
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||||
elif target == "expired":
|
elif target == "expired":
|
||||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
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:
|
else:
|
||||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
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})
|
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_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/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/broadcast", admin_broadcast_route)
|
||||||
router.add_post("/api/admin/sync", admin_sync_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()
|
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):
|
def _expired_subscription_exists_for_user(now: datetime):
|
||||||
expired_subs = aliased(Subscription)
|
expired_subs = aliased(Subscription)
|
||||||
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Textarea } from "$components/ui/index.js";
|
import { Textarea } from "$components/ui/index.js";
|
||||||
import { Send } from "$components/ui/icons.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 { Label } from "$components/ui/primitives.js";
|
||||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
const broadcastStore = getContext("broadcastStore");
|
const broadcastStore = getContext("broadcastStore");
|
||||||
|
|
||||||
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
|
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult, broadcastCounts } =
|
||||||
|
$broadcastStore);
|
||||||
|
|
||||||
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -24,7 +35,7 @@
|
|||||||
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
||||||
<AdminSelect
|
<AdminSelect
|
||||||
value={broadcastTarget}
|
value={broadcastTarget}
|
||||||
items={BROADCAST_TARGET_OPTIONS}
|
items={targetOptions}
|
||||||
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
|
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
|
||||||
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
broadcastText: "",
|
broadcastText: "",
|
||||||
broadcastBusy: false,
|
broadcastBusy: false,
|
||||||
broadcastResult: null,
|
broadcastResult: null,
|
||||||
|
broadcastCounts: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const BROADCAST_TARGET_OPTIONS = [
|
const BROADCAST_TARGET_OPTIONS = [
|
||||||
@@ -13,8 +14,23 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
|
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
|
||||||
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
|
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
|
||||||
{ value: "expired", label: at("broadcast_target_expired", {}, "Expired subscription") },
|
{ 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() {
|
async function runBroadcast() {
|
||||||
let text = "";
|
let text = "";
|
||||||
let target = "";
|
let target = "";
|
||||||
@@ -56,6 +72,7 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
update: state.update,
|
update: state.update,
|
||||||
runBroadcast,
|
runBroadcast,
|
||||||
updateField,
|
updateField,
|
||||||
|
loadCounts,
|
||||||
BROADCAST_TARGET_OPTIONS,
|
BROADCAST_TARGET_OPTIONS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,6 +658,12 @@ function demoApiResponse(path, cleanPath, options, context) {
|
|||||||
const params = queryParams(path);
|
const params = queryParams(path);
|
||||||
|
|
||||||
if (cleanPath === "/admin/stats") return clone(DEMO_DATASET.stats);
|
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/sync") return { ok: true, status: "queued" };
|
||||||
|
|
||||||
if (cleanPath === "/admin/payments") {
|
if (cleanPath === "/admin/payments") {
|
||||||
|
|||||||
@@ -1143,6 +1143,7 @@
|
|||||||
"admin_broadcast_target_active": "With subscription",
|
"admin_broadcast_target_active": "With subscription",
|
||||||
"admin_broadcast_target_inactive": "No subscription",
|
"admin_broadcast_target_inactive": "No subscription",
|
||||||
"admin_broadcast_target_expired": "Expired subscription",
|
"admin_broadcast_target_expired": "Expired subscription",
|
||||||
|
"admin_broadcast_target_never": "No subscription, no history",
|
||||||
"admin_expired_at": "Expired {date}",
|
"admin_expired_at": "Expired {date}",
|
||||||
"admin_expired_badge": "Expired {date}",
|
"admin_expired_badge": "Expired {date}",
|
||||||
"admin_stats_error": "Failed to load statistics: {error}",
|
"admin_stats_error": "Failed to load statistics: {error}",
|
||||||
|
|||||||
@@ -1143,6 +1143,7 @@
|
|||||||
"admin_broadcast_target_active": "С подпиской",
|
"admin_broadcast_target_active": "С подпиской",
|
||||||
"admin_broadcast_target_inactive": "Без подписки",
|
"admin_broadcast_target_inactive": "Без подписки",
|
||||||
"admin_broadcast_target_expired": "С просроченной подпиской",
|
"admin_broadcast_target_expired": "С просроченной подпиской",
|
||||||
|
"admin_broadcast_target_never": "Без подписки и без истории",
|
||||||
"admin_expired_at": "Истекла {date}",
|
"admin_expired_at": "Истекла {date}",
|
||||||
"admin_expired_badge": "Expired {date}",
|
"admin_expired_badge": "Expired {date}",
|
||||||
"admin_stats_error": "Не удалось загрузить статистику: {error}",
|
"admin_stats_error": "Не удалось загрузить статистику: {error}",
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route",
|
("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route",
|
||||||
("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route",
|
("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route",
|
||||||
("GET", "/api/admin/logs"): "admin_logs_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/broadcast"): "admin_broadcast_route",
|
||||||
("POST", "/api/admin/sync"): "admin_sync_route",
|
("POST", "/api/admin/sync"): "admin_sync_route",
|
||||||
("GET", "/api/admin/ads"): "admin_ads_list_route",
|
("GET", "/api/admin/ads"): "admin_ads_list_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user