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 +8 -0
- sub_amigo/adapters/__init__.py +3 -0
- sub_amigo/adapters/base.py +61 -0
- sub_amigo/admin.py +44 -0
- sub_amigo/apps.py +7 -0
- sub_amigo/engine/__init__.py +3 -0
- sub_amigo/engine/reminder_engine.py +272 -0
- sub_amigo/exceptions.py +10 -0
- sub_amigo/management/__init__.py +0 -0
- sub_amigo/management/commands/__init__.py +0 -0
- sub_amigo/management/commands/process_reminders.py +67 -0
- sub_amigo/migrations/0001_initial.py +227 -0
- sub_amigo/migrations/__init__.py +0 -0
- sub_amigo/models/__init__.py +12 -0
- sub_amigo/models/notification_log.py +58 -0
- sub_amigo/models/reminder_rule.py +40 -0
- sub_amigo/models/subscription.py +102 -0
- sub_amigo/py.typed +0 -0
- sub_amigo/service.py +128 -0
- sub_amigo/signals.py +23 -0
- sub_amigo-0.1.0.data/data/sub_amigo/py.typed +0 -0
- sub_amigo-0.1.0.dist-info/METADATA +331 -0
- sub_amigo-0.1.0.dist-info/RECORD +25 -0
- sub_amigo-0.1.0.dist-info/WHEEL +4 -0
- sub_amigo-0.1.0.dist-info/licenses/LICENSE +21 -0
sub_amigo/__init__.py
ADDED
|
@@ -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,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
|
sub_amigo/exceptions.py
ADDED
|
@@ -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()
|