sub-amigo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sub_amigo/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from .adapters.base import BaseNotificationAdapter, NotificationPayload
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = [
6
+ "BaseNotificationAdapter",
7
+ "NotificationPayload",
8
+ ]
@@ -0,0 +1,3 @@
1
+ from .base import BaseNotificationAdapter, NotificationPayload
2
+
3
+ __all__ = ["BaseNotificationAdapter", "NotificationPayload"]
@@ -0,0 +1,61 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class NotificationPayload:
8
+ """
9
+ Immutable value object passed to every notification adapter.
10
+
11
+ Attributes:
12
+ recipient: Opaque string identifying the notification target
13
+ (e.g. email address, Slack channel, user ID).
14
+ The adapter implementation decides how to interpret it.
15
+ subject: Short summary line suitable for email subjects or
16
+ push notification titles.
17
+ message: Fully-rendered body text.
18
+ metadata: Arbitrary key/value bag for adapter-specific extras
19
+ (e.g. subscription_id, billing_date).
20
+ """
21
+
22
+ recipient: str
23
+ subject: str
24
+ message: str
25
+ metadata: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class BaseNotificationAdapter(ABC):
29
+ """
30
+ Contract that all notification adapters must satisfy.
31
+
32
+ The engine calls ``send()`` with a ``NotificationPayload`` and expects a
33
+ boolean result. Concrete implementations must handle their own errors
34
+ internally and must not raise — return ``False`` instead.
35
+
36
+ Example implementation::
37
+
38
+ class EmailAdapter(BaseNotificationAdapter):
39
+ def send(self, payload: NotificationPayload) -> bool:
40
+ try:
41
+ send_mail(
42
+ subject=payload.subject,
43
+ message=payload.message,
44
+ from_email=settings.DEFAULT_FROM_EMAIL,
45
+ recipient_list=[payload.recipient],
46
+ )
47
+ return True
48
+ except Exception:
49
+ logger.exception("Email dispatch failed")
50
+ return False
51
+ """
52
+
53
+ @abstractmethod
54
+ def send(self, payload: NotificationPayload) -> bool:
55
+ """
56
+ Dispatch the notification payload to the recipient.
57
+
58
+ Returns:
59
+ ``True`` if the notification was delivered successfully.
60
+ ``False`` if delivery failed for any reason.
61
+ """
sub_amigo/admin.py ADDED
@@ -0,0 +1,44 @@
1
+ from django.contrib import admin
2
+
3
+ from .models import NotificationLog, ReminderRule, Subscription
4
+
5
+
6
+ class ReminderRuleInline(admin.TabularInline):
7
+ model = ReminderRule
8
+ extra = 0
9
+ fields = ("days_before", "message_template", "is_active")
10
+
11
+
12
+ @admin.register(Subscription)
13
+ class SubscriptionAdmin(admin.ModelAdmin):
14
+ list_display = ("name", "status", "billing_interval", "cost", "currency", "next_billing_date", "owner_ref")
15
+ list_filter = ("status", "billing_interval", "currency")
16
+ search_fields = ("name", "owner_ref", "recipient_ref")
17
+ ordering = ("next_billing_date",)
18
+ readonly_fields = ("id", "created_at", "updated_at")
19
+ inlines = [ReminderRuleInline]
20
+
21
+
22
+ @admin.register(NotificationLog)
23
+ class NotificationLogAdmin(admin.ModelAdmin):
24
+ list_display = ("subscription", "billing_cycle_date", "status", "triggered_at")
25
+ list_filter = ("status",)
26
+ search_fields = ("subscription__name", "recipient_ref")
27
+ ordering = ("-triggered_at",)
28
+ readonly_fields = (
29
+ "id",
30
+ "reminder_rule",
31
+ "subscription",
32
+ "billing_cycle_date",
33
+ "recipient_ref",
34
+ "rendered_message",
35
+ "status",
36
+ "error_detail",
37
+ "triggered_at",
38
+ )
39
+
40
+ def has_add_permission(self, request):
41
+ return False
42
+
43
+ def has_change_permission(self, request, obj=None):
44
+ return False
sub_amigo/apps.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class SubAmigoConfig(AppConfig):
5
+ name = "sub_amigo"
6
+ verbose_name = "Sub-Amigo"
7
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,3 @@
1
+ from .reminder_engine import ProcessingResult, ReminderResult, SubscriptionReminderEngine
2
+
3
+ __all__ = ["ProcessingResult", "ReminderResult", "SubscriptionReminderEngine"]
@@ -0,0 +1,272 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from datetime import date, timedelta
4
+ from typing import Optional
5
+
6
+ from ..adapters.base import BaseNotificationAdapter, NotificationPayload
7
+ from ..exceptions import EngineConfigurationError, TemplateRenderError
8
+ from ..models.notification_log import NotificationLog, NotificationStatus
9
+ from ..models.reminder_rule import ReminderRule
10
+ from ..models.subscription import Subscription, SubscriptionStatus
11
+ from ..signals import reminder_failed, reminder_sent
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ReminderResult:
18
+ rule_id: str
19
+ subscription_id: str
20
+ subscription_name: str
21
+ success: bool
22
+ skipped: bool = False
23
+ error: Optional[str] = None
24
+
25
+
26
+ @dataclass
27
+ class ProcessingResult:
28
+ run_date: date = field(default_factory=date.today)
29
+ processed: int = 0
30
+ succeeded: int = 0
31
+ failed: int = 0
32
+ skipped: int = 0
33
+ results: list[ReminderResult] = field(default_factory=list)
34
+
35
+
36
+ class SubscriptionReminderEngine:
37
+ """
38
+ Core processing engine. Finds all reminder rules due on a given date,
39
+ renders each rule's message template, dispatches notifications via the
40
+ injected adapter, and writes idempotency records to NotificationLog.
41
+
42
+ Usage::
43
+
44
+ engine = SubscriptionReminderEngine(adapter=MyEmailAdapter())
45
+ result = engine.process_daily_reminders()
46
+ """
47
+
48
+ _DEFAULT_SUBJECT = "Reminder: {subscription_name} renews on {next_billing_date}"
49
+
50
+ def __init__(self, adapter: BaseNotificationAdapter) -> None:
51
+ if not isinstance(adapter, BaseNotificationAdapter):
52
+ raise EngineConfigurationError(
53
+ f"adapter must subclass BaseNotificationAdapter, got {type(adapter).__name__!r}."
54
+ )
55
+ self._adapter = adapter
56
+
57
+ def process_daily_reminders(self, today: Optional[date] = None) -> ProcessingResult:
58
+ """
59
+ Entry point for scheduled execution (cron, Celery beat, management command).
60
+
61
+ For each active ReminderRule whose trigger date (next_billing_date - days_before)
62
+ equals ``today``, the engine:
63
+
64
+ 1. Skips rules already logged for this billing cycle (idempotent).
65
+ 2. Renders the message template.
66
+ 3. Calls adapter.send().
67
+ 4. Persists a NotificationLog entry.
68
+
69
+ The ``today`` parameter is intentionally injectable for deterministic testing.
70
+ """
71
+ run_date = today or date.today()
72
+ result = ProcessingResult(run_date=run_date)
73
+
74
+ due_rules = self._get_due_rules(run_date)
75
+
76
+ for rule in due_rules:
77
+ reminder_result = self._process_rule(rule, run_date)
78
+ result.processed += 1
79
+ result.results.append(reminder_result)
80
+
81
+ if reminder_result.skipped:
82
+ result.skipped += 1
83
+ elif reminder_result.success:
84
+ result.succeeded += 1
85
+ else:
86
+ result.failed += 1
87
+
88
+ logger.info(
89
+ "sub_amigo run_date=%s processed=%d succeeded=%d failed=%d skipped=%d",
90
+ run_date,
91
+ result.processed,
92
+ result.succeeded,
93
+ result.failed,
94
+ result.skipped,
95
+ )
96
+ return result
97
+
98
+ # ------------------------------------------------------------------
99
+ # Internal helpers
100
+ # ------------------------------------------------------------------
101
+
102
+ def _get_due_rules(self, run_date: date) -> list[ReminderRule]:
103
+ """
104
+ Return all active ReminderRules whose trigger date equals run_date,
105
+ excluding any already logged for their subscription's current billing cycle.
106
+
107
+ Strategy: fetch the distinct days_before values first (tiny query), then
108
+ issue one targeted query per unique offset. This avoids DB-side date
109
+ arithmetic and is portable across SQLite, PostgreSQL, and MySQL.
110
+ """
111
+ distinct_offsets: list[int] = list(
112
+ ReminderRule.objects.filter(
113
+ is_active=True,
114
+ subscription__status=SubscriptionStatus.ACTIVE,
115
+ )
116
+ .values_list("days_before", flat=True)
117
+ .distinct()
118
+ )
119
+
120
+ due_rules: list[ReminderRule] = []
121
+
122
+ for offset in distinct_offsets:
123
+ target_billing_date = run_date + timedelta(days=offset)
124
+
125
+ rules = (
126
+ ReminderRule.objects.filter(
127
+ is_active=True,
128
+ days_before=offset,
129
+ subscription__status=SubscriptionStatus.ACTIVE,
130
+ subscription__next_billing_date=target_billing_date,
131
+ )
132
+ .select_related("subscription")
133
+ .exclude(notification_logs__billing_cycle_date=target_billing_date)
134
+ )
135
+
136
+ due_rules.extend(rules)
137
+
138
+ return due_rules
139
+
140
+ def _process_rule(self, rule: ReminderRule, run_date: date) -> ReminderResult:
141
+ subscription: Subscription = rule.subscription
142
+ billing_date = subscription.next_billing_date
143
+
144
+ # Final idempotency guard against concurrent engine runs.
145
+ if NotificationLog.objects.filter(
146
+ reminder_rule=rule,
147
+ billing_cycle_date=billing_date,
148
+ ).exists():
149
+ logger.debug(
150
+ "sub_amigo skipping rule=%s subscription=%s billing_date=%s (already logged)",
151
+ rule.id,
152
+ subscription.id,
153
+ billing_date,
154
+ )
155
+ return ReminderResult(
156
+ rule_id=str(rule.id),
157
+ subscription_id=str(subscription.id),
158
+ subscription_name=subscription.name,
159
+ success=True,
160
+ skipped=True,
161
+ )
162
+
163
+ try:
164
+ rendered_message = self._render_template(rule, subscription)
165
+ except TemplateRenderError as exc:
166
+ logger.error("sub_amigo template error rule=%s: %s", rule.id, exc)
167
+ log = NotificationLog.objects.create(
168
+ reminder_rule=rule,
169
+ subscription=subscription,
170
+ billing_cycle_date=billing_date,
171
+ recipient_ref=subscription.recipient_ref,
172
+ rendered_message="",
173
+ status=NotificationStatus.FAILED,
174
+ error_detail=str(exc),
175
+ )
176
+ reminder_failed.send(
177
+ sender=self.__class__,
178
+ subscription=subscription,
179
+ rule=rule,
180
+ error=str(exc),
181
+ log=log,
182
+ )
183
+ return ReminderResult(
184
+ rule_id=str(rule.id),
185
+ subscription_id=str(subscription.id),
186
+ subscription_name=subscription.name,
187
+ success=False,
188
+ error=str(exc),
189
+ )
190
+
191
+ subject = self._DEFAULT_SUBJECT.format(
192
+ subscription_name=subscription.name,
193
+ next_billing_date=billing_date.isoformat(),
194
+ )
195
+ payload = NotificationPayload(
196
+ recipient=subscription.recipient_ref,
197
+ subject=subject,
198
+ message=rendered_message,
199
+ metadata={
200
+ "subscription_id": str(subscription.id),
201
+ "billing_date": billing_date.isoformat(),
202
+ "days_before": rule.days_before,
203
+ "currency": subscription.currency,
204
+ "cost": str(subscription.cost),
205
+ },
206
+ )
207
+
208
+ success = False
209
+ error_detail = ""
210
+
211
+ try:
212
+ success = self._adapter.send(payload)
213
+ except Exception as exc:
214
+ # Adapters should not raise, but we guard defensively.
215
+ error_detail = f"{type(exc).__name__}: {exc}"
216
+ logger.exception(
217
+ "sub_amigo adapter raised for rule=%s subscription=%s",
218
+ rule.id,
219
+ subscription.id,
220
+ )
221
+
222
+ log = NotificationLog.objects.create(
223
+ reminder_rule=rule,
224
+ subscription=subscription,
225
+ billing_cycle_date=billing_date,
226
+ recipient_ref=subscription.recipient_ref,
227
+ rendered_message=rendered_message,
228
+ status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
229
+ error_detail=error_detail,
230
+ )
231
+
232
+ if success:
233
+ reminder_sent.send(
234
+ sender=self.__class__,
235
+ subscription=subscription,
236
+ rule=rule,
237
+ payload=payload,
238
+ log=log,
239
+ )
240
+ else:
241
+ reminder_failed.send(
242
+ sender=self.__class__,
243
+ subscription=subscription,
244
+ rule=rule,
245
+ error=error_detail,
246
+ log=log,
247
+ )
248
+
249
+ return ReminderResult(
250
+ rule_id=str(rule.id),
251
+ subscription_id=str(subscription.id),
252
+ subscription_name=subscription.name,
253
+ success=success,
254
+ error=error_detail or None,
255
+ )
256
+
257
+ @staticmethod
258
+ def _render_template(rule: ReminderRule, subscription: Subscription) -> str:
259
+ context = {
260
+ "subscription_name": subscription.name,
261
+ "cost": subscription.cost,
262
+ "currency": subscription.currency,
263
+ "next_billing_date": subscription.next_billing_date.isoformat(),
264
+ "days_before": rule.days_before,
265
+ "description": subscription.description,
266
+ }
267
+ try:
268
+ return rule.message_template.format_map(context)
269
+ except KeyError as exc:
270
+ raise TemplateRenderError(
271
+ f"Unknown template variable {exc} in ReminderRule {rule.id}."
272
+ ) from exc
@@ -0,0 +1,10 @@
1
+ class SubAmigoError(Exception):
2
+ """Base exception for all sub-amigo errors."""
3
+
4
+
5
+ class EngineConfigurationError(SubAmigoError):
6
+ """Raised when the engine is constructed with an invalid adapter."""
7
+
8
+
9
+ class TemplateRenderError(SubAmigoError):
10
+ """Raised when a message template references an unknown variable."""
File without changes
File without changes
@@ -0,0 +1,67 @@
1
+ import importlib
2
+ from datetime import date
3
+
4
+ from django.conf import settings
5
+ from django.core.management.base import BaseCommand, CommandError
6
+
7
+ from sub_amigo.adapters.base import BaseNotificationAdapter
8
+ from sub_amigo.engine.reminder_engine import SubscriptionReminderEngine
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = "Process subscription reminders due today (or a given date)."
13
+
14
+ def add_arguments(self, parser) -> None:
15
+ parser.add_argument(
16
+ "--date",
17
+ type=date.fromisoformat,
18
+ default=None,
19
+ metavar="YYYY-MM-DD",
20
+ help="Process reminders for this date instead of today.",
21
+ )
22
+
23
+ def handle(self, *args, **options) -> None:
24
+ adapter = self._load_adapter()
25
+ engine = SubscriptionReminderEngine(adapter=adapter)
26
+ result = engine.process_daily_reminders(today=options["date"])
27
+
28
+ self.stdout.write(
29
+ f"Processed {result.processed} reminder(s) for {result.run_date}: "
30
+ f"{result.succeeded} sent, {result.failed} failed, {result.skipped} skipped."
31
+ )
32
+
33
+ for r in result.results:
34
+ if not r.success and not r.skipped:
35
+ self.stderr.write(
36
+ f" FAILED rule={r.rule_id} subscription={r.subscription_name!r}"
37
+ + (f" — {r.error}" if r.error else "")
38
+ )
39
+
40
+ if result.failed:
41
+ raise CommandError(f"{result.failed} reminder(s) failed to send.")
42
+
43
+ @staticmethod
44
+ def _load_adapter() -> BaseNotificationAdapter:
45
+ dotted_path: str = getattr(settings, "SUB_AMIGO_ADAPTER", "")
46
+ if not dotted_path:
47
+ raise CommandError(
48
+ "SUB_AMIGO_ADAPTER is not configured. "
49
+ "Add it to your Django settings, e.g.:\n"
50
+ " SUB_AMIGO_ADAPTER = 'myapp.adapters.EmailAdapter'"
51
+ )
52
+
53
+ module_path, class_name = dotted_path.rsplit(".", 1)
54
+ try:
55
+ module = importlib.import_module(module_path)
56
+ cls = getattr(module, class_name)
57
+ except (ImportError, AttributeError) as exc:
58
+ raise CommandError(
59
+ f"Could not import SUB_AMIGO_ADAPTER {dotted_path!r}: {exc}"
60
+ ) from exc
61
+
62
+ if not (isinstance(cls, type) and issubclass(cls, BaseNotificationAdapter)):
63
+ raise CommandError(
64
+ f"{dotted_path!r} must be a subclass of BaseNotificationAdapter."
65
+ )
66
+
67
+ return cls()