diff --git a/backend/bot/app/web/admin_api_impl/broadcast.py b/backend/bot/app/web/admin_api_impl/broadcast.py index 97f972e..b19bc0a 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"}: + 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) diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index 29ee2e2..f578cbd 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -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 @@ -127,12 +128,15 @@ async def _load_admin_users_list_payload_uncached( active_subs = await _bulk_active_subscriptions_for_users( session, [u.user_id for u in users] ) + payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users]) + referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users]) serialized = [] for user in users: payload = _serialize_user(user) status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None} payload["panel_status"] = status_payload.get("status") + payload["subscription_expires_at"] = status_payload.get("end_date") if status_payload.get("status") == "expired" and status_payload.get("end_date"): payload["panel_status_expired_at"] = status_payload["end_date"] payload["avatar_url"] = ( @@ -141,6 +145,11 @@ async def _load_admin_users_list_payload_uncached( else None ) payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id)) + payment_summary = payment_summaries.get(user.user_id) or {} + payload["payments_total_amount"] = float(payment_summary.get("total_amount") or 0) + payload["payments_count"] = int(payment_summary.get("count") or 0) + payload["payments_currency"] = payment_summary.get("currency") + payload["invited_users_count"] = int(referral_counts.get(user.user_id) or 0) serialized.append(payload) return { @@ -364,6 +373,88 @@ async def _bulk_active_subscriptions_for_users( return out +def _user_payment_summary_sq(): + return ( + select( + Payment.user_id.label("user_id"), + sa_func.coalesce(sa_func.sum(Payment.amount), 0.0).label("payments_total_amount"), + sa_func.count(Payment.payment_id).label("payments_count"), + ) + .where(Payment.status == "succeeded") + .group_by(Payment.user_id) + .subquery(name="user_payment_summary") + ) + + +def _user_referral_count_sq(): + referred_user = aliased(User) + return ( + select( + referred_user.referred_by_id.label("user_id"), + sa_func.count(referred_user.user_id).label("invited_users_count"), + ) + .where(referred_user.referred_by_id.is_not(None)) + .group_by(referred_user.referred_by_id) + .subquery(name="user_referral_count") + ) + + +def _user_subscription_expiry_sq(): + return ( + select( + Subscription.user_id.label("user_id"), + sa_func.max(Subscription.end_date).label("subscription_expires_at"), + ) + .group_by(Subscription.user_id) + .subquery(name="user_subscription_expiry") + ) + + +async def _bulk_user_payment_summaries( + session: AsyncSession, + user_ids: List[int], +) -> Dict[int, Dict[str, Any]]: + if not user_ids: + return {} + + stmt = ( + select( + Payment.user_id, + sa_func.coalesce(sa_func.sum(Payment.amount), 0.0), + sa_func.count(Payment.payment_id), + sa_func.max(Payment.currency), + ) + .where(Payment.user_id.in_(user_ids), Payment.status == "succeeded") + .group_by(Payment.user_id) + ) + rows = (await session.execute(stmt)).all() + return { + int(user_id): { + "total_amount": float(total_amount or 0), + "count": int(payments_count or 0), + "currency": currency, + } + for user_id, total_amount, payments_count, currency in rows + } + + +async def _bulk_user_referral_counts( + session: AsyncSession, + user_ids: List[int], +) -> Dict[int, int]: + if not user_ids: + return {} + + referred_user = aliased(User) + stmt = ( + select(referred_user.referred_by_id, sa_func.count(referred_user.user_id)) + .where(referred_user.referred_by_id.in_(user_ids)) + .group_by(referred_user.referred_by_id) + ) + rows = (await session.execute(stmt)).all() + return {int(user_id): int(count or 0) for user_id, count in rows} + + async def _filter_and_sort_users( session: AsyncSession, *, @@ -392,6 +483,13 @@ async def _filter_and_sort_users( ratio_expr = None plim_expr = None pu_expr = None + payment_summary_sq = None + payment_total_expr = None + payment_count_expr = None + referral_count_sq = None + referral_count_expr = None + subscription_expiry_sq = None + subscription_expires_expr = None if needs_premium_sq: sq = _ranked_active_subscriptions_sq(now) @@ -412,6 +510,42 @@ async def _filter_and_sort_users( else_=cast(pu_expr, Float) / cast(plim_expr, Float), ) + if sort_key in { + "payments_total_asc", + "payments_total_desc", + "payments_count_asc", + "payments_count_desc", + }: + payment_summary_sq = _user_payment_summary_sq() + stmt = stmt.outerjoin(payment_summary_sq, User.user_id == payment_summary_sq.c.user_id) + count_stmt = count_stmt.outerjoin( + payment_summary_sq, + User.user_id == payment_summary_sq.c.user_id, + ) + payment_total_expr = sa_func.coalesce(payment_summary_sq.c.payments_total_amount, 0.0) + payment_count_expr = sa_func.coalesce(payment_summary_sq.c.payments_count, 0) + + if sort_key in {"invited_users_count_asc", "invited_users_count_desc"}: + referral_count_sq = _user_referral_count_sq() + stmt = stmt.outerjoin(referral_count_sq, User.user_id == referral_count_sq.c.user_id) + count_stmt = count_stmt.outerjoin( + referral_count_sq, + User.user_id == referral_count_sq.c.user_id, + ) + referral_count_expr = sa_func.coalesce(referral_count_sq.c.invited_users_count, 0) + + if sort_key in {"subscription_expires_at_asc", "subscription_expires_at_desc"}: + subscription_expiry_sq = _user_subscription_expiry_sq() + stmt = stmt.outerjoin( + subscription_expiry_sq, + User.user_id == subscription_expiry_sq.c.user_id, + ) + count_stmt = count_stmt.outerjoin( + subscription_expiry_sq, + User.user_id == subscription_expiry_sq.c.user_id, + ) + subscription_expires_expr = subscription_expiry_sq.c.subscription_expires_at + search_cond = _user_search_condition(query) if search_cond is not None: stmt = stmt.where(search_cond) @@ -510,6 +644,22 @@ async def _filter_and_sort_users( stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc()) elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc": stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc()) + elif payment_total_expr is not None and sort_key == "payments_total_asc": + stmt = stmt.order_by(payment_total_expr.asc(), User.user_id.asc()) + elif payment_total_expr is not None and sort_key == "payments_total_desc": + stmt = stmt.order_by(payment_total_expr.desc(), User.user_id.desc()) + elif payment_count_expr is not None and sort_key == "payments_count_asc": + stmt = stmt.order_by(payment_count_expr.asc(), User.user_id.asc()) + elif payment_count_expr is not None and sort_key == "payments_count_desc": + stmt = stmt.order_by(payment_count_expr.desc(), User.user_id.desc()) + elif referral_count_expr is not None and sort_key == "invited_users_count_asc": + stmt = stmt.order_by(referral_count_expr.asc(), User.user_id.asc()) + elif referral_count_expr is not None and sort_key == "invited_users_count_desc": + stmt = stmt.order_by(referral_count_expr.desc(), User.user_id.desc()) + elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_asc": + stmt = stmt.order_by(subscription_expires_expr.asc().nullslast(), User.user_id.asc()) + elif subscription_expires_expr is not None and sort_key == "subscription_expires_at_desc": + stmt = stmt.order_by(subscription_expires_expr.desc().nullslast(), User.user_id.desc()) else: order = sort_map.get(sort_key, sort_map["registered_desc"]) if isinstance(order, tuple): @@ -538,9 +688,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" diff --git a/backend/bot/handlers/admin/broadcast.py b/backend/bot/handlers/admin/broadcast.py index 73b3f31..0e0754b 100644 --- a/backend/bot/handlers/admin/broadcast.py +++ b/backend/bot/handlers/admin/broadcast.py @@ -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) diff --git a/backend/bot/keyboards/inline/admin_keyboards.py b/backend/bot/keyboards/inline/admin_keyboards.py index 2b9530d..fdfd419 100644 --- a/backend/bot/keyboards/inline/admin_keyboards.py +++ b/backend/bot/keyboards/inline/admin_keyboards.py @@ -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() diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index cc2102e..4d0308b 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -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. diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index ab381fe..bd47e25 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -785,6 +785,7 @@ diff --git a/frontend/src/admin/sections/UsersSection.svelte b/frontend/src/admin/sections/UsersSection.svelte index a8b58ec..b9a4bc3 100644 --- a/frontend/src/admin/sections/UsersSection.svelte +++ b/frontend/src/admin/sections/UsersSection.svelte @@ -1,5 +1,12 @@