feat: add expired subscription broadcast target

This commit is contained in:
3252a8
2026-06-01 15:05:10 +03:00
parent 3d579b12d4
commit 6a40d0c9ce
13 changed files with 156 additions and 8 deletions
@@ -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)
+28 -2
View File
@@ -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"
+3 -1
View File
@@ -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)
@@ -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()
+62
View File
@@ -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.
@@ -728,7 +728,11 @@
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary">
{at("stats_trend_new_today", { count: users.active_today ?? 0 }, "")}
{at(
"stats_trend_expired_subscriptions",
{ count: users.expired_subscription_users ?? 0 },
""
)}
</div>
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
</Card.Footer>
@@ -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() {
+1
View File
@@ -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: {
+1
View File
@@ -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,
},
+3
View File
@@ -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)",
+3
View File
@@ -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)",
+1
View File
@@ -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,
}
)
+38
View File
@@ -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,