feat: add expired subscription broadcast target
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"}:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user