notifyfork 0.1.2__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.
- notifyfork/__init__.py +4 -0
- notifyfork/api/__init__.py +0 -0
- notifyfork/api/urls.py +5 -0
- notifyfork/api/webhooks/__init__.py +0 -0
- notifyfork/api/webhooks/resend_webhook.py +127 -0
- notifyfork/api/webhooks/sendgrid_webhook.py +124 -0
- notifyfork/api/webhooks/tasks.py +74 -0
- notifyfork/api/webhooks/twilio_webhook.py +121 -0
- notifyfork/api/webhooks/urls.py +10 -0
- notifyfork/client.py +63 -0
- notifyfork/core/__init__.py +0 -0
- notifyfork/core/application/__init__.py +0 -0
- notifyfork/core/application/dtos/__init__.py +0 -0
- notifyfork/core/application/dtos/send_notification_dto.py +25 -0
- notifyfork/core/application/interfaces/__init__.py +0 -0
- notifyfork/core/application/interfaces/notification_provider.py +43 -0
- notifyfork/core/application/interfaces/notification_repository.py +15 -0
- notifyfork/core/application/interfaces/template_repository.py +8 -0
- notifyfork/core/application/use_cases/__init__.py +0 -0
- notifyfork/core/application/use_cases/send_notification.py +91 -0
- notifyfork/core/domain/__init__.py +0 -0
- notifyfork/core/domain/entities/__init__.py +0 -0
- notifyfork/core/domain/entities/notification.py +141 -0
- notifyfork/core/domain/events/__init__.py +0 -0
- notifyfork/core/domain/events/notification_events.py +26 -0
- notifyfork/core/domain/value_objects/__init__.py +0 -0
- notifyfork/core/domain/value_objects/template.py +95 -0
- notifyfork/core/infrastructure/__init__.py +0 -0
- notifyfork/core/infrastructure/apps.py +7 -0
- notifyfork/core/infrastructure/container/__init__.py +3 -0
- notifyfork/core/infrastructure/container/providers.py +218 -0
- notifyfork/core/infrastructure/migrations/0001_initial.py +48 -0
- notifyfork/core/infrastructure/migrations/0002_seed_templates.py +116 -0
- notifyfork/core/infrastructure/migrations/0003_delivery_status.py +50 -0
- notifyfork/core/infrastructure/migrations/__init__.py +0 -0
- notifyfork/core/infrastructure/models/__init__.py +6 -0
- notifyfork/core/infrastructure/models/notification_model.py +98 -0
- notifyfork/core/infrastructure/providers/__init__.py +0 -0
- notifyfork/core/infrastructure/providers/firebase_provider.py +69 -0
- notifyfork/core/infrastructure/providers/resend_provider.py +83 -0
- notifyfork/core/infrastructure/providers/sendgrid_provider.py +150 -0
- notifyfork/core/infrastructure/providers/slack_provider.py +108 -0
- notifyfork/core/infrastructure/providers/smtp_provider.py +65 -0
- notifyfork/core/infrastructure/providers/twilio_provider.py +57 -0
- notifyfork/core/infrastructure/providers/whatsapp_provider.py +135 -0
- notifyfork/core/infrastructure/queue/__init__.py +0 -0
- notifyfork/core/infrastructure/queue/tasks.py +82 -0
- notifyfork/core/infrastructure/repositories/__init__.py +0 -0
- notifyfork/core/infrastructure/repositories/notification_repository.py +69 -0
- notifyfork/core/infrastructure/repositories/template_repository.py +23 -0
- notifyfork/shared/__init__.py +0 -0
- notifyfork/shared/exceptions/__init__.py +0 -0
- notifyfork/shared/exceptions/provider_exceptions.py +21 -0
- notifyfork/shared/logging/__init__.py +0 -0
- notifyfork/shared/logging/setup.py +38 -0
- notifyfork/shared/utils/__init__.py +0 -0
- notifyfork-0.1.2.dist-info/METADATA +599 -0
- notifyfork-0.1.2.dist-info/RECORD +60 -0
- notifyfork-0.1.2.dist-info/WHEEL +4 -0
- notifyfork-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import smtplib
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from email.mime.text import MIMEText
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
|
|
8
|
+
from notifyfork.core.domain.entities.notification import NotificationChannel
|
|
9
|
+
from notifyfork.core.domain.value_objects.template import NotificationTemplate
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SMTPEmailProvider(NotificationProvider):
|
|
15
|
+
"""
|
|
16
|
+
Generic SMTP email provider — renders templates locally.
|
|
17
|
+
|
|
18
|
+
Always LOCAL mode. Body is HTML rendered here before sending.
|
|
19
|
+
For external template rendering (SendGrid, Mailgun), use their
|
|
20
|
+
dedicated providers instead.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, host: str, port: int, username: str, password: str, from_email: str) -> None:
|
|
24
|
+
self._host = host
|
|
25
|
+
self._port = port
|
|
26
|
+
self._username = username
|
|
27
|
+
self._password = password
|
|
28
|
+
self._from_email = from_email
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return "smtp_email"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def supported_channels(self) -> list[NotificationChannel]:
|
|
36
|
+
# "email" — generic, eligible for fallback to sendgrid_email/resend_email.
|
|
37
|
+
# "smtp_email" (== self.name) — pins this exact vendor.
|
|
38
|
+
return [NotificationChannel.EMAIL, self.name]
|
|
39
|
+
|
|
40
|
+
async def send_with_template(
|
|
41
|
+
self,
|
|
42
|
+
recipient: str,
|
|
43
|
+
template: NotificationTemplate,
|
|
44
|
+
context: dict[str, Any],
|
|
45
|
+
) -> ProviderResult:
|
|
46
|
+
body = template.render(context)
|
|
47
|
+
subject = template.render_subject(context) or "(no subject)"
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
msg = MIMEMultipart("alternative")
|
|
51
|
+
msg["Subject"] = subject
|
|
52
|
+
msg["From"] = self._from_email
|
|
53
|
+
msg["To"] = recipient
|
|
54
|
+
msg.attach(MIMEText(body, "html"))
|
|
55
|
+
|
|
56
|
+
with smtplib.SMTP_SSL(self._host, self._port) as server:
|
|
57
|
+
server.login(self._username, self._password)
|
|
58
|
+
server.sendmail(self._from_email, recipient, msg.as_string())
|
|
59
|
+
|
|
60
|
+
logger.info("Email sent via SMTP", extra={"to": recipient, "subject": subject})
|
|
61
|
+
return ProviderResult(success=True, provider_name=self.name)
|
|
62
|
+
|
|
63
|
+
except smtplib.SMTPException as e:
|
|
64
|
+
logger.error("SMTP error", extra={"error": str(e)})
|
|
65
|
+
return ProviderResult(success=False, provider_name=self.name, error=str(e))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from twilio.rest import Client
|
|
5
|
+
from twilio.base.exceptions import TwilioRestException
|
|
6
|
+
|
|
7
|
+
from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
|
|
8
|
+
from notifyfork.core.domain.entities.notification import NotificationChannel
|
|
9
|
+
from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TwilioSMSProvider(NotificationProvider):
|
|
15
|
+
"""
|
|
16
|
+
Twilio SMS provider.
|
|
17
|
+
|
|
18
|
+
LOCAL mode — sends free-form text. Body is rendered locally.
|
|
19
|
+
EXTERNAL mode — not applicable for SMS (no template system).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, account_sid: str, auth_token: str, from_number: str) -> None:
|
|
23
|
+
self._client = Client(account_sid, auth_token)
|
|
24
|
+
self._from_number = from_number
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
return "twilio_sms"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def supported_channels(self) -> list[NotificationChannel]:
|
|
32
|
+
# "sms" — generic, eligible for fallback if another SMS provider is
|
|
33
|
+
# registered. "twilio_sms" (== self.name) — pins this exact vendor.
|
|
34
|
+
return [NotificationChannel.SMS, self.name]
|
|
35
|
+
|
|
36
|
+
async def send_with_template(
|
|
37
|
+
self,
|
|
38
|
+
recipient: str,
|
|
39
|
+
template: NotificationTemplate,
|
|
40
|
+
context: dict[str, Any],
|
|
41
|
+
) -> ProviderResult:
|
|
42
|
+
body = template.render(context)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
message = self._client.messages.create(
|
|
46
|
+
body=body,
|
|
47
|
+
from_=self._from_number,
|
|
48
|
+
to=recipient,
|
|
49
|
+
)
|
|
50
|
+
logger.info("SMS sent", extra={"sid": message.sid, "to": recipient[:6] + "***"})
|
|
51
|
+
return ProviderResult(success=True, provider_name=self.name, external_id=message.sid)
|
|
52
|
+
|
|
53
|
+
except TwilioRestException as e:
|
|
54
|
+
logger.error("Twilio SMS error", extra={"code": e.code, "msg": e.msg})
|
|
55
|
+
return ProviderResult(
|
|
56
|
+
success=False, provider_name=self.name, error=f"Twilio [{e.code}]: {e.msg}"
|
|
57
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from twilio.rest import Client
|
|
5
|
+
from twilio.base.exceptions import TwilioRestException
|
|
6
|
+
|
|
7
|
+
from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
|
|
8
|
+
from notifyfork.core.domain.entities.notification import NotificationChannel
|
|
9
|
+
from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Current WhatsApp vendor: Twilio (via the official `twilio` SDK, hence no
|
|
14
|
+
# hardcoded _API_URL like sendgrid_provider.py — the SDK's Client resolves
|
|
15
|
+
# https://api.twilio.com internally).
|
|
16
|
+
#
|
|
17
|
+
# This is the only WhatsApp provider today, but not the only one planned —
|
|
18
|
+
# e.g. Evolution API is a likely next candidate. Follow the SendGrid/SMTP
|
|
19
|
+
# pattern for that: a new file (evolution_provider.py) with its own
|
|
20
|
+
# EvolutionWhatsAppProvider class, registered for the same
|
|
21
|
+
# NotificationChannel.WHATSAPP, ordered/failed-over via
|
|
22
|
+
# NOTIFYFORK_PROVIDER_ORDER — not a branch inside this class.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TwilioWhatsAppProvider(NotificationProvider):
|
|
26
|
+
"""
|
|
27
|
+
WhatsApp via Twilio.
|
|
28
|
+
|
|
29
|
+
LOCAL mode — sandbox/dev only. Sends free-form text.
|
|
30
|
+
EXTERNAL mode — production. Uses Meta-approved template SID + mapped variables.
|
|
31
|
+
|
|
32
|
+
channel="whatsapp" stays generic even with a second provider (e.g. a future
|
|
33
|
+
Evolution API integration) registered for the same NotificationChannel.WHATSAPP —
|
|
34
|
+
same pattern as SendGrid/SMTP on channel="email". But that automatic fallback is
|
|
35
|
+
only safe for LOCAL-mode templates. A template in EXTERNAL mode here holds a
|
|
36
|
+
Twilio Content SID, which only this provider understands — a LOCAL-only WhatsApp
|
|
37
|
+
provider (like SMTP is for email) can't fall back to it. Keep EXTERNAL templates
|
|
38
|
+
provider-specific; don't rely on channel fallback across template modes.
|
|
39
|
+
|
|
40
|
+
Twilio WhatsApp external templates use positional variables: "1", "2", "3"...
|
|
41
|
+
Use VariableMapping to translate your semantic context to positional keys:
|
|
42
|
+
|
|
43
|
+
VariableMapping({"name": "1", "code": "2"})
|
|
44
|
+
|
|
45
|
+
So context {"name": "Mario", "code": "847291"} becomes {"1": "Mario", "2": "847291"}
|
|
46
|
+
which is what Twilio's ContentTemplate API expects.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, account_sid: str, auth_token: str, from_number: str) -> None:
|
|
50
|
+
self._client = Client(account_sid, auth_token)
|
|
51
|
+
self._from_number = (
|
|
52
|
+
from_number if from_number.startswith("whatsapp:") else f"whatsapp:{from_number}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def name(self) -> str:
|
|
57
|
+
return "twilio_whatsapp"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def supported_channels(self) -> list[NotificationChannel]:
|
|
61
|
+
# "whatsapp" — generic, eligible for automatic fallback if another
|
|
62
|
+
# WhatsApp provider (e.g. Evolution) is registered later.
|
|
63
|
+
# "twilio_whatsapp" (== self.name) — pins this exact vendor, no fallback.
|
|
64
|
+
return [NotificationChannel.WHATSAPP, self.name]
|
|
65
|
+
|
|
66
|
+
async def send_with_template(
|
|
67
|
+
self,
|
|
68
|
+
recipient: str,
|
|
69
|
+
template: NotificationTemplate,
|
|
70
|
+
context: dict[str, Any],
|
|
71
|
+
) -> ProviderResult:
|
|
72
|
+
to = recipient if recipient.startswith("whatsapp:") else f"whatsapp:{recipient}"
|
|
73
|
+
|
|
74
|
+
if template.mode == TemplateMode.EXTERNAL:
|
|
75
|
+
return await self._send_external(to, template, context)
|
|
76
|
+
return await self._send_local(to, template, context)
|
|
77
|
+
|
|
78
|
+
async def _send_external(
|
|
79
|
+
self,
|
|
80
|
+
to: str,
|
|
81
|
+
template: NotificationTemplate,
|
|
82
|
+
context: dict[str, Any],
|
|
83
|
+
) -> ProviderResult:
|
|
84
|
+
"""
|
|
85
|
+
Sends using a Meta-approved Twilio Content Template.
|
|
86
|
+
Variables are translated via VariableMapping before dispatch.
|
|
87
|
+
"""
|
|
88
|
+
translated = template.translate_variables(context)
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
"Sending WhatsApp via external template",
|
|
92
|
+
extra={
|
|
93
|
+
"template_sid": template.external_template_id,
|
|
94
|
+
"to": to[:12] + "***",
|
|
95
|
+
"variables": list(translated.keys()),
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
message = self._client.messages.create(
|
|
101
|
+
content_sid=template.external_template_id,
|
|
102
|
+
content_variables=str(translated), # Twilio expects JSON string
|
|
103
|
+
from_=self._from_number,
|
|
104
|
+
to=to,
|
|
105
|
+
)
|
|
106
|
+
return ProviderResult(
|
|
107
|
+
success=True, provider_name=self.name, external_id=message.sid
|
|
108
|
+
)
|
|
109
|
+
except TwilioRestException as e:
|
|
110
|
+
logger.error("WhatsApp external template error", extra={"code": e.code, "msg": e.msg})
|
|
111
|
+
return ProviderResult(
|
|
112
|
+
success=False,
|
|
113
|
+
provider_name=self.name,
|
|
114
|
+
error=f"Twilio WhatsApp [{e.code}]: {e.msg}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def _send_local(
|
|
118
|
+
self,
|
|
119
|
+
to: str,
|
|
120
|
+
template: NotificationTemplate,
|
|
121
|
+
context: dict[str, Any],
|
|
122
|
+
) -> ProviderResult:
|
|
123
|
+
"""Free-form message — only valid in sandbox/dev."""
|
|
124
|
+
body = template.render(context)
|
|
125
|
+
try:
|
|
126
|
+
message = self._client.messages.create(body=body, from_=self._from_number, to=to)
|
|
127
|
+
return ProviderResult(
|
|
128
|
+
success=True, provider_name=self.name, external_id=message.sid
|
|
129
|
+
)
|
|
130
|
+
except TwilioRestException as e:
|
|
131
|
+
return ProviderResult(
|
|
132
|
+
success=False,
|
|
133
|
+
provider_name=self.name,
|
|
134
|
+
error=f"Twilio WhatsApp [{e.code}]: {e.msg}",
|
|
135
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from celery import shared_task
|
|
5
|
+
from celery.utils.log import get_task_logger
|
|
6
|
+
|
|
7
|
+
logger = get_task_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@shared_task(
|
|
11
|
+
bind=True,
|
|
12
|
+
max_retries=3,
|
|
13
|
+
default_retry_delay=60, # 1 min first retry
|
|
14
|
+
retry_backoff=True, # exponential: 1m, 2m, 4m
|
|
15
|
+
retry_backoff_max=600, # cap at 10 minutes
|
|
16
|
+
acks_late=True, # only ack after task completes — safe on worker crash
|
|
17
|
+
reject_on_worker_lost=True,
|
|
18
|
+
)
|
|
19
|
+
def dispatch_notification(self, payload: dict) -> dict:
|
|
20
|
+
"""
|
|
21
|
+
Celery task that drives notification dispatch.
|
|
22
|
+
|
|
23
|
+
acks_late=True + reject_on_worker_lost=True ensures that if a
|
|
24
|
+
worker dies mid-execution, the task is re-queued instead of lost.
|
|
25
|
+
|
|
26
|
+
Retry strategy uses exponential backoff to avoid hammering
|
|
27
|
+
a provider that's temporarily down (thundering herd prevention).
|
|
28
|
+
"""
|
|
29
|
+
from notifyfork.core.application.dtos.send_notification_dto import SendNotificationDTO
|
|
30
|
+
from notifyfork.core.infrastructure.container import Container
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
dto = SendNotificationDTO(**payload)
|
|
34
|
+
use_case = Container.send_notification_use_case()
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
notification_id = asyncio.get_event_loop().run_until_complete(use_case.execute(dto))
|
|
38
|
+
|
|
39
|
+
logger.info("Task completed", extra={"notification_id": str(notification_id)})
|
|
40
|
+
return {"notification_id": str(notification_id), "status": "dispatched"}
|
|
41
|
+
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
logger.warning(
|
|
44
|
+
"Task failed, scheduling retry",
|
|
45
|
+
extra={"attempt": self.request.retries + 1, "error": str(exc)},
|
|
46
|
+
)
|
|
47
|
+
raise self.retry(exc=exc)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@shared_task
|
|
51
|
+
def retry_pending_notifications() -> dict:
|
|
52
|
+
"""
|
|
53
|
+
Periodic task (beat) that picks up notifications stuck in RETRYING state.
|
|
54
|
+
|
|
55
|
+
Prevents silent failures from getting lost if the worker
|
|
56
|
+
crashed before persisting the final status.
|
|
57
|
+
|
|
58
|
+
Schedule this via Celery Beat — e.g. every 5 minutes.
|
|
59
|
+
"""
|
|
60
|
+
from notifyfork.core.infrastructure.container import Container
|
|
61
|
+
|
|
62
|
+
repository = Container.notification_repository()
|
|
63
|
+
|
|
64
|
+
import asyncio
|
|
65
|
+
pending = asyncio.get_event_loop().run_until_complete(
|
|
66
|
+
repository.get_pending_retries(limit=100)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
queued = 0
|
|
70
|
+
for notification in pending:
|
|
71
|
+
if notification.can_retry:
|
|
72
|
+
dispatch_notification.delay({
|
|
73
|
+
"recipient": notification.recipient,
|
|
74
|
+
"channel": notification.channel,
|
|
75
|
+
"notification_type": notification.notification_type,
|
|
76
|
+
"template_id": notification.template_id,
|
|
77
|
+
"context": notification.context,
|
|
78
|
+
})
|
|
79
|
+
queued += 1
|
|
80
|
+
|
|
81
|
+
logger.info("Retry sweep complete", extra={"queued": queued})
|
|
82
|
+
return {"retried": queued}
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from notifyfork.core.application.interfaces.notification_repository import NotificationRepository
|
|
5
|
+
from notifyfork.core.domain.entities.notification import Notification, NotificationStatus
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DjangoNotificationRepository(NotificationRepository):
|
|
11
|
+
|
|
12
|
+
async def save(self, notification: Notification) -> None:
|
|
13
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
14
|
+
await NotificationModel.objects.aupdate_or_create(
|
|
15
|
+
id=notification.id,
|
|
16
|
+
defaults={
|
|
17
|
+
"recipient": notification.recipient,
|
|
18
|
+
"channel": notification.channel,
|
|
19
|
+
"notification_type": notification.notification_type,
|
|
20
|
+
"template_id": notification.template_id,
|
|
21
|
+
"context": notification.context,
|
|
22
|
+
"status": notification.status.value,
|
|
23
|
+
"provider_used": notification.provider_used,
|
|
24
|
+
"attempts": notification.attempts,
|
|
25
|
+
"max_attempts": notification.max_attempts,
|
|
26
|
+
"error_detail": notification.error_detail,
|
|
27
|
+
"sent_at": notification.sent_at,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async def get_by_id(self, notification_id: UUID) -> Notification | None:
|
|
32
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
33
|
+
try:
|
|
34
|
+
obj = await NotificationModel.objects.aget(id=notification_id)
|
|
35
|
+
return self._to_entity(obj)
|
|
36
|
+
except NotificationModel.DoesNotExist:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
async def get_pending_retries(self, limit: int = 100) -> list[Notification]:
|
|
40
|
+
"""
|
|
41
|
+
Single bounded query — never loads all records then filters in Python.
|
|
42
|
+
N+1 safe: one query, one trip to the database.
|
|
43
|
+
"""
|
|
44
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
45
|
+
qs = (
|
|
46
|
+
NotificationModel.objects
|
|
47
|
+
.filter(status=NotificationModel.StatusChoices.RETRYING)
|
|
48
|
+
.order_by("created_at")[:limit]
|
|
49
|
+
)
|
|
50
|
+
return [self._to_entity(obj) async for obj in qs]
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _to_entity(obj) -> Notification:
|
|
54
|
+
n = Notification(
|
|
55
|
+
recipient=obj.recipient,
|
|
56
|
+
channel=obj.channel,
|
|
57
|
+
notification_type=obj.notification_type,
|
|
58
|
+
template_id=obj.template_id,
|
|
59
|
+
context=obj.context,
|
|
60
|
+
id=obj.id,
|
|
61
|
+
attempts=obj.attempts,
|
|
62
|
+
max_attempts=obj.max_attempts,
|
|
63
|
+
error_detail=obj.error_detail,
|
|
64
|
+
created_at=obj.created_at,
|
|
65
|
+
sent_at=obj.sent_at,
|
|
66
|
+
provider_used=obj.provider_used,
|
|
67
|
+
)
|
|
68
|
+
n.status = NotificationStatus(obj.status)
|
|
69
|
+
return n
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from notifyfork.core.application.interfaces.template_repository import TemplateRepository
|
|
3
|
+
from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode, VariableMapping
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabaseTemplateRepository(TemplateRepository):
|
|
9
|
+
|
|
10
|
+
async def get_by_id(self, template_id: str) -> NotificationTemplate | None:
|
|
11
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationTemplateModel
|
|
12
|
+
try:
|
|
13
|
+
obj = await NotificationTemplateModel.objects.aget(id=template_id, is_active=True)
|
|
14
|
+
return NotificationTemplate(
|
|
15
|
+
id=obj.id,
|
|
16
|
+
body=obj.body,
|
|
17
|
+
subject=obj.subject,
|
|
18
|
+
mode=TemplateMode(obj.mode),
|
|
19
|
+
variable_mapping=VariableMapping(obj.variable_mapping or {}),
|
|
20
|
+
)
|
|
21
|
+
except NotificationTemplateModel.DoesNotExist:
|
|
22
|
+
logger.warning("Template not found", extra={"template_id": template_id})
|
|
23
|
+
return None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class NotificationGatewayError(Exception):
|
|
2
|
+
"""Base exception for the notification gateway domain."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NoProviderAvailable(NotificationGatewayError):
|
|
6
|
+
def __init__(self, channel: str) -> None:
|
|
7
|
+
super().__init__(f"No provider registered for channel: {channel}")
|
|
8
|
+
self.channel = channel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TemplateNotFound(NotificationGatewayError):
|
|
12
|
+
def __init__(self, template_id: str) -> None:
|
|
13
|
+
super().__init__(f"Template not found: {template_id}")
|
|
14
|
+
self.template_id = template_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderDeliveryError(NotificationGatewayError):
|
|
18
|
+
def __init__(self, provider: str, reason: str) -> None:
|
|
19
|
+
super().__init__(f"Provider '{provider}' failed: {reason}")
|
|
20
|
+
self.provider = provider
|
|
21
|
+
self.reason = reason
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Any
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JSONFormatter(logging.Formatter):
|
|
8
|
+
"""
|
|
9
|
+
Structured JSON logs — plays nicely with GCP Cloud Logging,
|
|
10
|
+
Datadog, and any log aggregator that parses JSON.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
14
|
+
log: dict[str, Any] = {
|
|
15
|
+
"severity": record.levelname,
|
|
16
|
+
"message": record.getMessage(),
|
|
17
|
+
"logger": record.name,
|
|
18
|
+
"timestamp": self.formatTime(record),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if hasattr(record, "__dict__"):
|
|
22
|
+
extras = {
|
|
23
|
+
k: v
|
|
24
|
+
for k, v in record.__dict__.items()
|
|
25
|
+
if k not in logging.LogRecord.__dict__ and not k.startswith("_")
|
|
26
|
+
}
|
|
27
|
+
log.update(extras)
|
|
28
|
+
|
|
29
|
+
if record.exc_info:
|
|
30
|
+
log["exception"] = self.formatException(record.exc_info)
|
|
31
|
+
|
|
32
|
+
return json.dumps(log)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
36
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
37
|
+
handler.setFormatter(JSONFormatter())
|
|
38
|
+
logging.basicConfig(level=level, handlers=[handler])
|
|
File without changes
|