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()
|
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"}:
|
if target not in {"all", "active", "inactive", "expired"}:
|
||||||
target = "all"
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
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)
|
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||||
elif target == "inactive":
|
elif target == "inactive":
|
||||||
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":
|
||||||
|
user_ids = await user_dal.get_user_ids_with_expired_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)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import hashlib
|
|||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
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.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||||
from bot.infra.redis import cache_delete_pattern, redis_key
|
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)
|
normalized_status == "active", blank_status & Subscription.is_active.is_(True)
|
||||||
)
|
)
|
||||||
elif status == "expired":
|
elif status == "expired":
|
||||||
status_cond = or_(
|
now = datetime.now(timezone.utc)
|
||||||
normalized_status == "expired", blank_status & Subscription.is_active.is_(False)
|
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:
|
else:
|
||||||
status_cond = normalized_status == "limited"
|
status_cond = normalized_status == "limited"
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ async def change_broadcast_target_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_target = callback.data.split(":")[1]
|
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)
|
await callback.answer("Unknown target.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -247,6 +247,8 @@ async def confirm_broadcast_callback_handler(
|
|||||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||||
elif target == "inactive":
|
elif target == "inactive":
|
||||||
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":
|
||||||
|
user_ids = await user_dal.get_user_ids_with_expired_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)
|
||||||
|
|
||||||
|
|||||||
@@ -452,10 +452,11 @@ def get_broadcast_confirmation_keyboard(
|
|||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
# Row: target selection (all / active / inactive)
|
# Row: target selection (all / active / inactive / expired)
|
||||||
target_all_label = _(key="broadcast_target_all_button")
|
target_all_label = _(key="broadcast_target_all_button")
|
||||||
target_active_label = _(key="broadcast_target_active_button")
|
target_active_label = _(key="broadcast_target_active_button")
|
||||||
target_inactive_label = _(key="broadcast_target_inactive_button")
|
target_inactive_label = _(key="broadcast_target_inactive_button")
|
||||||
|
target_expired_label = _(key="broadcast_target_expired_button")
|
||||||
|
|
||||||
# Highlight current selection with a prefix
|
# Highlight current selection with a prefix
|
||||||
def mark_selected(label: str, is_selected: bool) -> str:
|
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"),
|
text=mark_selected(target_inactive_label, target == "inactive"),
|
||||||
callback_data="broadcast_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
|
# Row: confirmation
|
||||||
builder.button(
|
builder.button(
|
||||||
@@ -482,7 +486,7 @@ def get_broadcast_confirmation_keyboard(
|
|||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel"
|
||||||
)
|
)
|
||||||
builder.adjust(2)
|
builder.adjust(2, 2, 2)
|
||||||
return builder.as_markup()
|
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)
|
free_subscription_users = int(subscription_counts[3] or 0)
|
||||||
|
|
||||||
inactive_users = total_users - active_subscription_users
|
inactive_users = total_users - active_subscription_users
|
||||||
|
expired_subscription_users = await count_users_with_expired_subscription(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
@@ -787,6 +788,7 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
|
|||||||
"trial_users": trial_users,
|
"trial_users": trial_users,
|
||||||
"free_subscription_users": free_subscription_users,
|
"free_subscription_users": free_subscription_users,
|
||||||
"inactive_users": max(0, inactive_users),
|
"inactive_users": max(0, inactive_users),
|
||||||
|
"expired_subscription_users": expired_subscription_users,
|
||||||
"referral_users": referral_users,
|
"referral_users": referral_users,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +843,66 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
return result.scalars().all()
|
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:
|
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||||
"""Completely remove a user and all dependent records from the database.
|
"""Completely remove a user and all dependent records from the database.
|
||||||
|
|
||||||
|
|||||||
@@ -728,7 +728,11 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Footer class="admin-cn-card-footer--stack">
|
<Card.Footer class="admin-cn-card-footer--stack">
|
||||||
<div class="admin-cn-card-footer-primary">
|
<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>
|
||||||
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
|
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
|
||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
|
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
|
||||||
{ 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") },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runBroadcast() {
|
async function runBroadcast() {
|
||||||
|
|||||||
@@ -82665,6 +82665,7 @@ export const DEMO_DATASET = {
|
|||||||
paid_subscriptions: 177,
|
paid_subscriptions: 177,
|
||||||
trial_users: 0,
|
trial_users: 0,
|
||||||
inactive_users: 193,
|
inactive_users: 193,
|
||||||
|
expired_subscription_users: 97,
|
||||||
referral_users: 106,
|
referral_users: 106,
|
||||||
},
|
},
|
||||||
financial: {
|
financial: {
|
||||||
|
|||||||
@@ -1278,6 +1278,7 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
trial_users: 8,
|
trial_users: 8,
|
||||||
free_subscription_users: 23,
|
free_subscription_users: 23,
|
||||||
inactive_users: 76,
|
inactive_users: 76,
|
||||||
|
expired_subscription_users: 31,
|
||||||
banned_users: 3,
|
banned_users: 3,
|
||||||
referral_users: 34,
|
referral_users: 34,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
"broadcast_target_all_button": "👥 All",
|
"broadcast_target_all_button": "👥 All",
|
||||||
"broadcast_target_active_button": "✅ Active",
|
"broadcast_target_active_button": "✅ Active",
|
||||||
"broadcast_target_inactive_button": "⌛ Inactive",
|
"broadcast_target_inactive_button": "⌛ Inactive",
|
||||||
|
"broadcast_target_expired_button": "⏰ Expired",
|
||||||
"confirm_broadcast_send_button": "✅ Send",
|
"confirm_broadcast_send_button": "✅ Send",
|
||||||
"admin_broadcast_sending_started": "Starting broadcast...",
|
"admin_broadcast_sending_started": "Starting broadcast...",
|
||||||
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
|
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
|
||||||
@@ -1127,6 +1128,7 @@
|
|||||||
"admin_broadcast_target_all": "All active",
|
"admin_broadcast_target_all": "All active",
|
||||||
"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_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}",
|
||||||
@@ -1154,6 +1156,7 @@
|
|||||||
"admin_stats_trend_referrals": "Referrals: {count}",
|
"admin_stats_trend_referrals": "Referrals: {count}",
|
||||||
"admin_stats_label_inactive": "No active subscription",
|
"admin_stats_label_inactive": "No active subscription",
|
||||||
"admin_stats_trend_new_today": "Registrations today: {count}",
|
"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": "Revenue",
|
||||||
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
|
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
|
||||||
"admin_stats_revenue_chart_title": "Daily revenue (UTC)",
|
"admin_stats_revenue_chart_title": "Daily revenue (UTC)",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
"broadcast_target_all_button": "👥 Все",
|
"broadcast_target_all_button": "👥 Все",
|
||||||
"broadcast_target_active_button": "✅ Активные",
|
"broadcast_target_active_button": "✅ Активные",
|
||||||
"broadcast_target_inactive_button": "⌛ Неактивные",
|
"broadcast_target_inactive_button": "⌛ Неактивные",
|
||||||
|
"broadcast_target_expired_button": "⏰ Просроченные",
|
||||||
"confirm_broadcast_send_button": "✅ Отправить",
|
"confirm_broadcast_send_button": "✅ Отправить",
|
||||||
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
||||||
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
||||||
@@ -1127,6 +1128,7 @@
|
|||||||
"admin_broadcast_target_all": "Все активные",
|
"admin_broadcast_target_all": "Все активные",
|
||||||
"admin_broadcast_target_active": "С подпиской",
|
"admin_broadcast_target_active": "С подпиской",
|
||||||
"admin_broadcast_target_inactive": "Без подписки",
|
"admin_broadcast_target_inactive": "Без подписки",
|
||||||
|
"admin_broadcast_target_expired": "С просроченной подпиской",
|
||||||
"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}",
|
||||||
@@ -1154,6 +1156,7 @@
|
|||||||
"admin_stats_trend_referrals": "Рефералы: {count}",
|
"admin_stats_trend_referrals": "Рефералы: {count}",
|
||||||
"admin_stats_label_inactive": "Без активной подписки",
|
"admin_stats_label_inactive": "Без активной подписки",
|
||||||
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
|
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
|
||||||
|
"admin_stats_trend_expired_subscriptions": "С просроченной подпиской: {count}",
|
||||||
"admin_stats_section_revenue": "Доходы",
|
"admin_stats_section_revenue": "Доходы",
|
||||||
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
|
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
|
||||||
"admin_stats_revenue_chart_title": "Выручка по дням (UTC)",
|
"admin_stats_revenue_chart_title": "Выручка по дням (UTC)",
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"trial_users": 1,
|
"trial_users": 1,
|
||||||
"free_subscription_users": 0,
|
"free_subscription_users": 0,
|
||||||
"inactive_users": 2,
|
"inactive_users": 2,
|
||||||
|
"expired_subscription_users": 1,
|
||||||
"referral_users": 3,
|
"referral_users": 3,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
side_effect=[
|
side_effect=[
|
||||||
FakeResult((10, 1, 2, 3)),
|
FakeResult((10, 1, 2, 3)),
|
||||||
FakeResult((8, 4, 2, 2)),
|
FakeResult((8, 4, 2, 2)),
|
||||||
|
FakeResult(3),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,7 @@ class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"trial_users": 2,
|
"trial_users": 2,
|
||||||
"free_subscription_users": 2,
|
"free_subscription_users": 2,
|
||||||
"inactive_users": 2,
|
"inactive_users": 2,
|
||||||
|
"expired_subscription_users": 3,
|
||||||
"referral_users": 3,
|
"referral_users": 3,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -211,6 +213,42 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("LEFT OUTER JOIN", sql)
|
self.assertIn("LEFT OUTER JOIN", sql)
|
||||||
self.assertIn("IS NULL", 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):
|
async def test_merge_users_uses_bulk_updates_for_related_tables(self):
|
||||||
source = SimpleNamespace(
|
source = SimpleNamespace(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
|
|||||||
Reference in New Issue
Block a user