feature: Add local subscription notification worker

This commit is contained in:
3252a8
2026-05-28 22:48:48 +03:00
parent 1c1ef06e29
commit d299a3c524
15 changed files with 561 additions and 1 deletions
@@ -409,6 +409,14 @@ SETTINGS_MANIFEST: List[SettingField] = [
"За сколько дней предупреждать",
min=0,
),
SettingField(
"SUBSCRIPTION_NOTIFY_HOURS_BEFORE",
"int",
"notifications",
"За сколько часов предупреждать",
min=0,
max=23,
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
@@ -0,0 +1,346 @@
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import Bot
from aiogram.utils.text_decorations import html_decoration as hd
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, sessionmaker
from bot.infra.redis import redis_lock
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import subscription_dal
from db.models import Subscription
SUBSCRIPTION_NOTIFICATION_LOCK = "subscription-notification-worker"
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS = 300
EXPIRED_NOTIFICATION_WINDOW = timedelta(hours=24)
EXPIRED_AFTER_NOTIFICATION_WINDOW = timedelta(hours=48)
@dataclass(frozen=True)
class SubscriptionNotificationStage:
key: str
message_key: str
hours_before: Optional[int] = None
class SubscriptionNotificationWorker:
def __init__(
self,
settings: Settings,
session_factory: sessionmaker,
bot: Bot,
i18n: JsonI18n,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
) -> None:
self.settings = settings
self.session_factory = session_factory
self.bot = bot
self.i18n = i18n
self.panel_service = panel_service
self.subscription_service = subscription_service
self._stopped = asyncio.Event()
async def run(self) -> None:
while not self._stopped.is_set():
try:
async with redis_lock(
self.settings,
SUBSCRIPTION_NOTIFICATION_LOCK,
ttl_seconds=max(60, self._tick_seconds() - 10),
) as acquired:
if not acquired:
logging.info(
"SubscriptionNotificationWorker tick skipped: Redis lock is held"
)
else:
started = time.monotonic()
async with self.session_factory() as session:
await self.expiry_tick(session)
await self.trial_traffic_tick(session)
await session.commit()
logging.info(
"metric worker_tick_duration_seconds=%.3f "
"worker=subscription_notification",
time.monotonic() - started,
)
except Exception:
logging.exception("SubscriptionNotificationWorker tick failed")
try:
await asyncio.wait_for(self._stopped.wait(), timeout=self._tick_seconds())
except asyncio.TimeoutError:
pass
def stop(self) -> None:
self._stopped.set()
def _tick_seconds(self) -> int:
return int(
getattr(
self.settings,
"SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS",
DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS,
)
or DEFAULT_SUBSCRIPTION_NOTIFICATION_TICK_SECONDS
)
async def expiry_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
lower = now - EXPIRED_AFTER_NOTIFICATION_WINDOW
upper = now + self._max_before_window()
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.end_date >= lower,
Subscription.end_date <= upper,
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
stage = self.stage_for_subscription(sub, now)
if stage is None:
continue
if await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
stage.key,
):
continue
if not await self._send_expiry_notification(sub, stage):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
stage.key,
sent_at=now,
)
def stage_for_subscription(
self,
sub: Subscription,
now: datetime,
) -> Optional[SubscriptionNotificationStage]:
end_date = self._as_utc(getattr(sub, "end_date", None))
if end_date is None:
return None
seconds_left = (end_date - now).total_seconds()
if seconds_left > 0:
hours_before = int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0)
if 0 < hours_before <= 23 and seconds_left <= hours_before * 3600:
return SubscriptionNotificationStage(
key=f"before_{hours_before}h",
message_key="subscription_hours_notification",
hours_before=hours_before,
)
days_before_limit = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
)
day_stages = (
(1, "subscription_24h_notification"),
(2, "subscription_48h_notification"),
(3, "subscription_72h_notification"),
)
for days_before, message_key in day_stages:
if days_before > days_before_limit:
continue
if seconds_left <= days_before * 24 * 3600:
return SubscriptionNotificationStage(
key=f"before_{days_before}d",
message_key=message_key,
)
return None
expired_for = now - end_date
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_ON_EXPIRE", True)
and expired_for <= EXPIRED_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired",
message_key="subscription_expired_notification",
)
if (
getattr(self.settings, "SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", True)
and EXPIRED_NOTIFICATION_WINDOW < expired_for <= EXPIRED_AFTER_NOTIFICATION_WINDOW
):
return SubscriptionNotificationStage(
key="expired_24h_after",
message_key="subscription_expired_yesterday_notification",
)
return None
async def trial_traffic_tick(self, session: AsyncSession) -> None:
if not getattr(self.settings, "SUBSCRIPTION_NOTIFICATIONS_ENABLED", True):
return
now = datetime.now(timezone.utc)
result = await session.execute(
select(Subscription)
.where(
Subscription.skip_notifications == False,
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.traffic_limit_bytes.is_not(None),
Subscription.traffic_limit_bytes > 0,
or_(
Subscription.provider == "trial",
Subscription.status_from_panel == "TRIAL",
Subscription.duration_months == 0,
),
)
.options(selectinload(Subscription.user))
.order_by(Subscription.end_date.asc())
)
for sub in result.scalars().all():
if await subscription_dal.has_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
):
continue
used = int(getattr(sub, "traffic_used_bytes", 0) or 0)
limit = int(getattr(sub, "traffic_limit_bytes", 0) or 0)
panel_data = await self._panel_user(sub)
if panel_data:
panel_used, panel_limit, _ = (
self.subscription_service._extract_panel_traffic_details(panel_data)
)
if panel_used is not None:
used = int(panel_used)
sub.traffic_used_bytes = used
if panel_limit is not None:
limit = int(panel_limit)
sub.traffic_limit_bytes = limit
panel_status = str(panel_data.get("status") or "").upper()
if panel_status:
sub.status_from_panel = panel_status
if limit <= 0 or used < limit:
continue
if not await self._send_trial_traffic_depleted(sub, used=used, limit=limit):
continue
await subscription_dal.record_subscription_notification(
session,
sub.subscription_id,
"trial_traffic_depleted",
sent_at=now,
)
async def _panel_user(self, sub: Subscription) -> Optional[dict]:
panel_uuid = str(getattr(sub, "panel_user_uuid", "") or "").strip()
if not panel_uuid:
return None
try:
data = await self.panel_service.get_user_by_uuid(panel_uuid, log_response=False)
except Exception:
logging.exception(
"SubscriptionNotificationWorker: failed to fetch panel user %s",
panel_uuid,
)
return None
return data if isinstance(data, dict) else None
async def _send_expiry_notification(
self,
sub: Subscription,
stage: SubscriptionNotificationStage,
) -> bool:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_name = getattr(user, "first_name", None) or f"User {user_id}"
end_date = self._as_utc(getattr(sub, "end_date", None))
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
kwargs = {"user_name": user_name, "end_date": end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
try:
await self.bot.send_message(
user_id,
translate(stage.message_key, **kwargs),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
)
return True
except Exception:
logging.exception(
"Failed to send subscription notification %s to user %s",
stage.key,
user_id,
)
return False
async def _send_trial_traffic_depleted(
self,
sub: Subscription,
*,
used: int,
limit: int,
) -> bool:
user_id = int(getattr(sub, "user_id", 0) or 0)
if user_id <= 0:
return False
user = getattr(sub, "user", None)
lang = getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
translate = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
remaining = max(0, limit - used)
try:
await self.bot.send_message(
user_id,
translate(
"trial_traffic_depleted_notification",
used=hd.quote(self._fmt_bytes(used)),
remaining=hd.quote(self._fmt_bytes(remaining)),
limit_total=hd.quote(self._fmt_bytes(limit)),
),
reply_markup=get_subscribe_only_markup(lang, self.i18n),
parse_mode="HTML",
)
return True
except Exception:
logging.exception("Failed to send trial traffic depleted warning to user %s", user_id)
return False
def _max_before_window(self) -> timedelta:
days_before = max(0, int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0))
hours_before = max(
0,
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_HOURS_BEFORE", 0) or 0),
)
return max(timedelta(days=min(days_before, 3)), timedelta(hours=hours_before))
@staticmethod
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@staticmethod
def _fmt_bytes(value: int) -> str:
size = float(max(0, int(value or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} TB"
@@ -62,6 +62,7 @@ class TrialSubscriptionMixin:
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False,
"provider": "trial",
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
+2
View File
@@ -243,6 +243,8 @@ class Settings(BaseSettings):
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
SUBSCRIPTION_NOTIFY_HOURS_BEFORE: int = Field(default=3)
SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS: int = Field(default=300)
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH"
+40 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from db.models import Subscription
from db.models import Subscription, SubscriptionNotification
INSTALL_SHARE_TOKEN_BYTES = 16
@@ -318,6 +318,45 @@ async def update_subscription_notification_time(
)
async def has_subscription_notification(
session: AsyncSession,
subscription_id: int,
notification_key: str,
) -> bool:
stmt = (
select(SubscriptionNotification.notification_id)
.where(
SubscriptionNotification.subscription_id == subscription_id,
SubscriptionNotification.notification_key == notification_key,
)
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def record_subscription_notification(
session: AsyncSession,
subscription_id: int,
notification_key: str,
*,
sent_at: Optional[datetime] = None,
) -> None:
if sent_at is None:
sent_at = datetime.now(timezone.utc)
existing = await has_subscription_notification(session, subscription_id, notification_key)
if existing:
return
session.add(
SubscriptionNotification(
subscription_id=subscription_id,
notification_key=notification_key,
sent_at=sent_at,
)
)
await update_subscription_notification_time(session, subscription_id, sent_at)
async def find_subscription_for_notification_update(
session: AsyncSession, user_id: int, subscription_end_date_to_match: datetime
) -> Optional[Subscription]:
+6
View File
@@ -18,6 +18,7 @@ from ..models import (
Payment,
PromoCodeActivation,
Subscription,
SubscriptionNotification,
SupportTicket,
SupportTicketMessage,
TariffChange,
@@ -779,6 +780,11 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.execute(
delete(TrafficWarning).where(TrafficWarning.subscription_id.in_(subscription_ids))
)
await session.execute(
delete(SubscriptionNotification).where(
SubscriptionNotification.subscription_id.in_(subscription_ids)
)
)
await session.execute(
delete(SupportTicketMessage).where(SupportTicketMessage.ticket_id.in_(support_ticket_ids))
)
+40
View File
@@ -1012,6 +1012,41 @@ def _migration_0030_add_hwid_pricing_metadata(connection: Connection) -> None:
)
def _migration_0031_add_subscription_notifications(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS subscription_notifications (
notification_id SERIAL PRIMARY KEY,
subscription_id INTEGER NOT NULL REFERENCES subscriptions(subscription_id),
notification_key VARCHAR(64) NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_subscription_notification_key UNIQUE (
subscription_id,
notification_key
)
)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_subscription_id
ON subscription_notifications (subscription_id)
"""
)
)
connection.execute(
text(
"""
CREATE INDEX IF NOT EXISTS ix_subscription_notifications_notification_key
ON subscription_notifications (notification_key)
"""
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -1174,6 +1209,11 @@ MIGRATIONS: List[Migration] = [
description="Persist quoted HWID top-up pricing windows and conversion audit",
upgrade=_migration_0030_add_hwid_pricing_metadata,
),
Migration(
id="0031_add_subscription_notifications",
description="Track sent subscription notification stages",
upgrade=_migration_0031_add_subscription_notifications,
),
]
+20
View File
@@ -277,6 +277,26 @@ class TrafficWarning(Base):
subscription = relationship("Subscription")
class SubscriptionNotification(Base):
__tablename__ = "subscription_notifications"
__table_args__ = (
UniqueConstraint(
"subscription_id",
"notification_key",
name="uq_subscription_notification_key",
),
)
notification_id = Column(Integer, primary_key=True, autoincrement=True)
subscription_id = Column(
Integer, ForeignKey("subscriptions.subscription_id"), nullable=False, index=True
)
notification_key = Column(String(64), nullable=False, index=True)
sent_at = Column(DateTime(timezone=True), server_default=func.now())
subscription = relationship("Subscription")
class TariffChange(Base):
__tablename__ = "tariff_changes"
+15
View File
@@ -25,6 +25,7 @@ from bot.payment_providers.yookassa import (
)
from bot.services.backup_worker import BackupWorker
from bot.services.locale_override_service import load_locale_overrides
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
from bot.services.tariff_worker import TariffTrafficWorker
from bot.utils.message_queue import init_queue_manager
from config.settings import get_settings
@@ -192,6 +193,20 @@ async def main() -> None:
tasks = []
if settings.tariffs_config:
tasks.append(asyncio.create_task(tariff_worker.run(), name="TariffTrafficWorker"))
subscription_notification_worker = SubscriptionNotificationWorker(
settings,
session_factory,
bot,
i18n,
services["panel_service"],
services["subscription_service"],
)
tasks.append(
asyncio.create_task(
subscription_notification_worker.run(),
name="SubscriptionNotificationWorker",
)
)
backup_worker = BackupWorker(settings, bot, session_factory=session_factory)
tasks.append(asyncio.create_task(backup_worker.run(), name="BackupWorker"))
tasks.append(asyncio.create_task(_panel_sync_loop(settings, session_factory, i18n, services)))
+2
View File
@@ -388,6 +388,8 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `SUBSCRIPTION_NOTIFY_ON_EXPIRE` | Уведомлять в день окончания. |
| `SUBSCRIPTION_NOTIFY_AFTER_EXPIRE` | Уведомлять после окончания. |
| `SUBSCRIPTION_NOTIFY_DAYS_BEFORE` | За сколько дней предупреждать. |
| `SUBSCRIPTION_NOTIFY_HOURS_BEFORE` | За сколько часов предупреждать дополнительно. |
| `SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS` | Период локальной проверки уведомлений. |
## Поддержка
+3
View File
@@ -257,8 +257,10 @@
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_hours_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in {hours} hours — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"trial_traffic_depleted_notification": "⛔️ Trial traffic is used up.\n\n<b>Used:</b> {used}\n<b>Remaining:</b> {remaining}\n<b>Limit:</b> {limit_total}\n\nTo keep using the service, buy a subscription using the button below.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
@@ -1559,6 +1561,7 @@
"admin_settings_field_subscription_notify_on_expire_label": "Subscription Notify On Expire",
"admin_settings_field_subscription_notify_after_expire_label": "Subscription Notify After Expire",
"admin_settings_field_subscription_notify_days_before_label": "Subscription Notify Days Before",
"admin_settings_field_subscription_notify_hours_before_label": "Subscription Notify Hours Before",
"admin_settings_field_log_new_users_label": "Log New Users",
"admin_settings_field_log_payments_label": "Log Payments",
"admin_settings_field_log_promo_activations_label": "Log Promo Activations",
+3
View File
@@ -190,8 +190,10 @@
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_hours_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через {hours} ч. — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"trial_traffic_depleted_notification": "⛔️ Трафик пробного периода израсходован.\n\n<b>Использовано:</b> {used}\n<b>Осталось:</b> {remaining}\n<b>Лимит:</b> {limit_total}\n\nЧтобы продолжить пользоваться сервисом, оформите подписку по кнопке ниже.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
@@ -1559,6 +1561,7 @@
"admin_settings_field_subscription_notify_on_expire_label": "Уведомлять об истечении",
"admin_settings_field_subscription_notify_after_expire_label": "Уведомлять после истечения",
"admin_settings_field_subscription_notify_days_before_label": "За сколько дней предупреждать",
"admin_settings_field_subscription_notify_hours_before_label": "За сколько часов предупреждать",
"admin_settings_field_log_new_users_label": "Логировать новых пользователей",
"admin_settings_field_log_payments_label": "Логировать платежи",
"admin_settings_field_log_promo_activations_label": "Логировать активации промокодов",
+11
View File
@@ -271,3 +271,14 @@ class SettingsTests(unittest.TestCase):
)
self.assertEqual(settings.tariff_traffic_warning_levels, [85, 90, 95])
def test_subscription_hour_notification_default_is_available(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertEqual(settings.SUBSCRIPTION_NOTIFY_HOURS_BEFORE, 3)
self.assertEqual(settings.SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS, 300)
@@ -0,0 +1,60 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from bot.services.subscription_notification_worker import SubscriptionNotificationWorker
def _worker(**overrides):
settings = SimpleNamespace(
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3,
SUBSCRIPTION_NOTIFY_HOURS_BEFORE=3,
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True,
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True,
SUBSCRIPTION_NOTIFICATION_WORKER_TICK_SECONDS=300,
**overrides,
)
return SubscriptionNotificationWorker(
settings=settings,
session_factory=object(),
bot=object(),
i18n=object(),
panel_service=object(),
subscription_service=object(),
)
def _sub(end_date):
return SimpleNamespace(end_date=end_date)
def test_stage_prefers_hour_reminder_over_day_backlog():
now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc)
stage = _worker().stage_for_subscription(_sub(now + timedelta(hours=2, minutes=30)), now)
assert stage.key == "before_3h"
assert stage.message_key == "subscription_hours_notification"
assert stage.hours_before == 3
def test_stage_uses_most_imminent_day_reminder():
now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc)
stage = _worker().stage_for_subscription(_sub(now + timedelta(hours=23)), now)
assert stage.key == "before_1d"
assert stage.message_key == "subscription_24h_notification"
def test_stage_sends_expired_during_first_day_after_end():
now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc)
stage = _worker().stage_for_subscription(_sub(now - timedelta(hours=1)), now)
assert stage.key == "expired"
assert stage.message_key == "subscription_expired_notification"
def test_stage_sends_yesterday_notice_only_after_first_day():
now = datetime(2026, 5, 28, 12, tzinfo=timezone.utc)
stage = _worker().stage_for_subscription(_sub(now - timedelta(hours=25)), now)
assert stage.key == "expired_24h_after"
assert stage.message_key == "subscription_expired_yesterday_notification"
+4
View File
@@ -83,6 +83,10 @@ class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
delete_tables.index("traffic_warnings"),
delete_tables.index("subscriptions"),
)
self.assertLess(
delete_tables.index("subscription_notifications"),
delete_tables.index("subscriptions"),
)
self.assertLess(
delete_tables.index("promo_code_activations"),
delete_tables.index("payments"),