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.
Files changed (60) hide show
  1. notifyfork/__init__.py +4 -0
  2. notifyfork/api/__init__.py +0 -0
  3. notifyfork/api/urls.py +5 -0
  4. notifyfork/api/webhooks/__init__.py +0 -0
  5. notifyfork/api/webhooks/resend_webhook.py +127 -0
  6. notifyfork/api/webhooks/sendgrid_webhook.py +124 -0
  7. notifyfork/api/webhooks/tasks.py +74 -0
  8. notifyfork/api/webhooks/twilio_webhook.py +121 -0
  9. notifyfork/api/webhooks/urls.py +10 -0
  10. notifyfork/client.py +63 -0
  11. notifyfork/core/__init__.py +0 -0
  12. notifyfork/core/application/__init__.py +0 -0
  13. notifyfork/core/application/dtos/__init__.py +0 -0
  14. notifyfork/core/application/dtos/send_notification_dto.py +25 -0
  15. notifyfork/core/application/interfaces/__init__.py +0 -0
  16. notifyfork/core/application/interfaces/notification_provider.py +43 -0
  17. notifyfork/core/application/interfaces/notification_repository.py +15 -0
  18. notifyfork/core/application/interfaces/template_repository.py +8 -0
  19. notifyfork/core/application/use_cases/__init__.py +0 -0
  20. notifyfork/core/application/use_cases/send_notification.py +91 -0
  21. notifyfork/core/domain/__init__.py +0 -0
  22. notifyfork/core/domain/entities/__init__.py +0 -0
  23. notifyfork/core/domain/entities/notification.py +141 -0
  24. notifyfork/core/domain/events/__init__.py +0 -0
  25. notifyfork/core/domain/events/notification_events.py +26 -0
  26. notifyfork/core/domain/value_objects/__init__.py +0 -0
  27. notifyfork/core/domain/value_objects/template.py +95 -0
  28. notifyfork/core/infrastructure/__init__.py +0 -0
  29. notifyfork/core/infrastructure/apps.py +7 -0
  30. notifyfork/core/infrastructure/container/__init__.py +3 -0
  31. notifyfork/core/infrastructure/container/providers.py +218 -0
  32. notifyfork/core/infrastructure/migrations/0001_initial.py +48 -0
  33. notifyfork/core/infrastructure/migrations/0002_seed_templates.py +116 -0
  34. notifyfork/core/infrastructure/migrations/0003_delivery_status.py +50 -0
  35. notifyfork/core/infrastructure/migrations/__init__.py +0 -0
  36. notifyfork/core/infrastructure/models/__init__.py +6 -0
  37. notifyfork/core/infrastructure/models/notification_model.py +98 -0
  38. notifyfork/core/infrastructure/providers/__init__.py +0 -0
  39. notifyfork/core/infrastructure/providers/firebase_provider.py +69 -0
  40. notifyfork/core/infrastructure/providers/resend_provider.py +83 -0
  41. notifyfork/core/infrastructure/providers/sendgrid_provider.py +150 -0
  42. notifyfork/core/infrastructure/providers/slack_provider.py +108 -0
  43. notifyfork/core/infrastructure/providers/smtp_provider.py +65 -0
  44. notifyfork/core/infrastructure/providers/twilio_provider.py +57 -0
  45. notifyfork/core/infrastructure/providers/whatsapp_provider.py +135 -0
  46. notifyfork/core/infrastructure/queue/__init__.py +0 -0
  47. notifyfork/core/infrastructure/queue/tasks.py +82 -0
  48. notifyfork/core/infrastructure/repositories/__init__.py +0 -0
  49. notifyfork/core/infrastructure/repositories/notification_repository.py +69 -0
  50. notifyfork/core/infrastructure/repositories/template_repository.py +23 -0
  51. notifyfork/shared/__init__.py +0 -0
  52. notifyfork/shared/exceptions/__init__.py +0 -0
  53. notifyfork/shared/exceptions/provider_exceptions.py +21 -0
  54. notifyfork/shared/logging/__init__.py +0 -0
  55. notifyfork/shared/logging/setup.py +38 -0
  56. notifyfork/shared/utils/__init__.py +0 -0
  57. notifyfork-0.1.2.dist-info/METADATA +599 -0
  58. notifyfork-0.1.2.dist-info/RECORD +60 -0
  59. notifyfork-0.1.2.dist-info/WHEEL +4 -0
  60. notifyfork-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,91 @@
1
+ import logging
2
+ from uuid import UUID
3
+
4
+ from notifyfork.core.application.dtos.send_notification_dto import SendNotificationDTO
5
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider
6
+ from notifyfork.core.application.interfaces.notification_repository import NotificationRepository
7
+ from notifyfork.core.application.interfaces.template_repository import TemplateRepository
8
+ from notifyfork.core.domain.entities.notification import Notification
9
+ from notifyfork.shared.exceptions.provider_exceptions import NoProviderAvailable, TemplateNotFound
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SendNotificationUseCase:
15
+ """
16
+ Orchestrates notification dispatch.
17
+
18
+ Does not know whether templates are local or external —
19
+ that's the provider's responsibility.
20
+ Does not know how Twilio, SendGrid or Firebase work.
21
+ Knows only: get template, pick providers, persist state, deliver.
22
+
23
+ When more than one provider supports the channel, tries them in order
24
+ (see Container's provider ordering) and falls through to the next on
25
+ failure. notification.provider_used always records which one actually
26
+ sent it, and each fallback is logged as it happens.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ repository: NotificationRepository,
32
+ template_repository: TemplateRepository,
33
+ providers: list[NotificationProvider],
34
+ ) -> None:
35
+ self._repository = repository
36
+ self._template_repository = template_repository
37
+ self._providers = providers
38
+
39
+ async def execute(self, dto: SendNotificationDTO) -> UUID:
40
+ template = await self._template_repository.get_by_id(dto.template_id)
41
+ if not template:
42
+ raise TemplateNotFound(dto.template_id)
43
+
44
+ candidates = self._resolve_providers(dto.channel)
45
+
46
+ notification = Notification(
47
+ recipient=dto.recipient,
48
+ channel=dto.channel,
49
+ notification_type=dto.notification_type,
50
+ template_id=dto.template_id,
51
+ context=dto.context,
52
+ max_attempts=dto.max_attempts,
53
+ )
54
+
55
+ notification.mark_queued()
56
+ await self._repository.save(notification)
57
+
58
+ error = "Unknown provider error"
59
+ for index, provider in enumerate(candidates):
60
+ # Provider handles LOCAL vs EXTERNAL rendering internally
61
+ result = await provider.send_with_template(
62
+ recipient=dto.recipient,
63
+ template=template,
64
+ context=dto.context,
65
+ )
66
+ if result.success:
67
+ notification.mark_sent(provider.name)
68
+ break
69
+
70
+ error = result.error or "Unknown provider error"
71
+ has_fallback = index < len(candidates) - 1
72
+ logger.warning(
73
+ "Provider failed" + (", falling back to next provider" if has_fallback else ""),
74
+ extra={
75
+ "provider": provider.name,
76
+ "channel": dto.channel,
77
+ "error": error,
78
+ "fallback_available": has_fallback,
79
+ },
80
+ )
81
+ else:
82
+ notification.mark_failed(error)
83
+
84
+ await self._repository.save(notification)
85
+ return notification.id
86
+
87
+ def _resolve_providers(self, channel) -> list[NotificationProvider]:
88
+ candidates = [p for p in self._providers if p.supports(channel)]
89
+ if not candidates:
90
+ raise NoProviderAvailable(channel)
91
+ return candidates
File without changes
File without changes
@@ -0,0 +1,141 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any
5
+ from uuid import UUID, uuid4
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class NotificationStatus(str, Enum):
12
+ PENDING = "pending"
13
+ QUEUED = "queued"
14
+ SENT = "sent" # provider accepted — not yet confirmed delivered
15
+ DELIVERED = "delivered" # provider confirmed delivery to recipient
16
+ DELIVERY_FAILED = "delivery_failed" # provider confirmed it could NOT deliver
17
+ FAILED = "failed" # our system exhausted all retry attempts
18
+ RETRYING = "retrying"
19
+
20
+
21
+ class NotificationChannel(str, Enum):
22
+ SMS = "sms"
23
+ EMAIL = "email"
24
+ PUSH = "push"
25
+ WHATSAPP = "whatsapp"
26
+ SLACK = "slack"
27
+
28
+
29
+ class NotificationType(str, Enum):
30
+ TRANSACTIONAL = "transactional"
31
+ ALERT = "alert"
32
+ MARKETING = "marketing"
33
+
34
+
35
+ @dataclass
36
+ class Notification:
37
+ """
38
+ Core domain entity. Owns its own state machine.
39
+
40
+ State flow:
41
+ PENDING → QUEUED → SENT ──────────────→ DELIVERED (confirmed by provider webhook)
42
+ ↘ RETRYING → ↗ (retry succeeded)
43
+ → DELIVERY_FAILED (provider confirmed failure)
44
+ → FAILED (our retries exhausted)
45
+
46
+ SENT means "provider accepted" — not "recipient got it".
47
+ DELIVERED means the provider webhook confirmed actual delivery.
48
+ DELIVERY_FAILED means the provider confirmed it could not deliver
49
+ (bad number, full inbox, uninstalled app, etc).
50
+ """
51
+
52
+ recipient: str
53
+ channel: str
54
+ notification_type: str
55
+ template_id: str
56
+ context: dict[str, Any] = field(default_factory=dict)
57
+ id: UUID = field(default_factory=uuid4)
58
+ status: NotificationStatus = field(default=NotificationStatus.PENDING)
59
+ provider_used: str | None = None
60
+ provider_message_id: str | None = None # external ID for webhook correlation
61
+ attempts: int = 0
62
+ max_attempts: int = 3
63
+ error_detail: str | None = None
64
+ created_at: datetime = field(default_factory=datetime.utcnow)
65
+ sent_at: datetime | None = None
66
+ delivered_at: datetime | None = None
67
+
68
+ def mark_queued(self) -> None:
69
+ logger.info("Notification queued", extra={
70
+ "notification_id": str(self.id), "channel": self.channel,
71
+ })
72
+ self.status = NotificationStatus.QUEUED
73
+
74
+ def mark_sent(self, provider: str, provider_message_id: str | None = None) -> None:
75
+ self.status = NotificationStatus.SENT
76
+ self.provider_used = provider
77
+ self.provider_message_id = provider_message_id
78
+ self.sent_at = datetime.utcnow()
79
+ logger.info("Notification sent — awaiting delivery confirmation", extra={
80
+ "notification_id": str(self.id),
81
+ "provider": provider,
82
+ "provider_message_id": provider_message_id,
83
+ "channel": self.channel,
84
+ })
85
+
86
+ def mark_delivered(self) -> None:
87
+ """Called when provider webhook confirms delivery to recipient."""
88
+ if self.status != NotificationStatus.SENT:
89
+ logger.warning("mark_delivered called on non-SENT notification", extra={
90
+ "notification_id": str(self.id), "current_status": self.status,
91
+ })
92
+ self.status = NotificationStatus.DELIVERED
93
+ self.delivered_at = datetime.utcnow()
94
+ logger.info("Notification delivered", extra={
95
+ "notification_id": str(self.id),
96
+ "provider": self.provider_used,
97
+ "latency_ms": int((self.delivered_at - self.sent_at).total_seconds() * 1000)
98
+ if self.sent_at else None,
99
+ })
100
+
101
+ def mark_delivery_failed(self, reason: str) -> None:
102
+ """Called when provider webhook confirms it could NOT deliver."""
103
+ self.status = NotificationStatus.DELIVERY_FAILED
104
+ self.error_detail = reason
105
+ logger.error("Delivery failed (provider confirmed)", extra={
106
+ "notification_id": str(self.id),
107
+ "provider": self.provider_used,
108
+ "reason": reason,
109
+ })
110
+
111
+ def mark_failed(self, reason: str) -> None:
112
+ """Called when our retry logic gives up."""
113
+ self.attempts += 1
114
+ self.error_detail = reason
115
+ if self.attempts >= self.max_attempts:
116
+ self.status = NotificationStatus.FAILED
117
+ logger.error("Notification permanently failed", extra={
118
+ "notification_id": str(self.id),
119
+ "attempts": self.attempts,
120
+ "reason": reason,
121
+ })
122
+ else:
123
+ self.status = NotificationStatus.RETRYING
124
+ logger.warning("Notification failed, will retry", extra={
125
+ "notification_id": str(self.id),
126
+ "attempt": self.attempts,
127
+ "max_attempts": self.max_attempts,
128
+ "reason": reason,
129
+ })
130
+
131
+ @property
132
+ def can_retry(self) -> bool:
133
+ return self.attempts < self.max_attempts
134
+
135
+ @property
136
+ def is_terminal(self) -> bool:
137
+ return self.status in (
138
+ NotificationStatus.DELIVERED,
139
+ NotificationStatus.DELIVERY_FAILED,
140
+ NotificationStatus.FAILED,
141
+ )
File without changes
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from uuid import UUID
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class NotificationQueued:
8
+ notification_id: UUID
9
+ channel: str
10
+ recipient: str
11
+ occurred_at: datetime = field(default_factory=datetime.utcnow)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class NotificationSent:
16
+ notification_id: UUID
17
+ provider: str
18
+ occurred_at: datetime = field(default_factory=datetime.utcnow)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class NotificationFailed:
23
+ notification_id: UUID
24
+ reason: str
25
+ attempts: int
26
+ occurred_at: datetime = field(default_factory=datetime.utcnow)
File without changes
@@ -0,0 +1,95 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+ from string import Template
4
+ from typing import Any
5
+
6
+
7
+ class TemplateMode(str, Enum):
8
+ LOCAL = "local" # rendered here — body is the content
9
+ EXTERNAL = "external" # rendered by provider — body is the template ID
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class VariableMapping:
14
+ """
15
+ Maps our semantic variable names to provider-specific keys.
16
+
17
+ Twilio WhatsApp uses positional keys: {"name": "1", "code": "2"}
18
+ SendGrid uses named keys: {"name": "user_name", "code": "otp_code"}
19
+ Mailgun uses the same name: {"name": "name", "code": "code"}
20
+
21
+ This keeps your context payload consistent regardless of provider.
22
+ You always send {"name": "Mario", "code": "123"} and the mapping
23
+ translates to whatever the provider expects.
24
+ """
25
+ mappings: dict[str, str] = field(default_factory=dict)
26
+
27
+ def translate(self, context: dict[str, Any]) -> dict[str, Any]:
28
+ """
29
+ Translates context keys using the mapping.
30
+ Keys not in the mapping are passed through as-is.
31
+ """
32
+ if not self.mappings:
33
+ return context
34
+ return {
35
+ self.mappings.get(k, k): v
36
+ for k, v in context.items()
37
+ }
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class NotificationTemplate:
42
+ """
43
+ Unified template value object supporting local and external rendering.
44
+
45
+ LOCAL mode:
46
+ body is the actual content with $variable placeholders.
47
+ Rendered here using Python's string.Template.
48
+
49
+ EXTERNAL mode:
50
+ body is the external template ID (e.g. Twilio SID "HXabc123",
51
+ SendGrid ID "d-abc123", Mailgun template name "otp-email").
52
+ variable_mapping translates our context to provider-expected keys.
53
+ Provider receives the ID + translated variables — renders on their end.
54
+ """
55
+
56
+ id: str
57
+ body: str # content OR external template ID
58
+ mode: TemplateMode = TemplateMode.LOCAL
59
+ subject: str | None = None # email subject (LOCAL mode)
60
+ variable_mapping: VariableMapping = field(default_factory=VariableMapping)
61
+
62
+ @property
63
+ def is_external(self) -> bool:
64
+ return self.mode == TemplateMode.EXTERNAL
65
+
66
+ @property
67
+ def external_template_id(self) -> str:
68
+ """Alias for clarity when in EXTERNAL mode."""
69
+ if not self.is_external:
70
+ raise ValueError("external_template_id is only valid in EXTERNAL mode")
71
+ return self.body
72
+
73
+ def render(self, context: dict[str, Any]) -> str:
74
+ """Render body locally. Only valid in LOCAL mode."""
75
+ if self.is_external:
76
+ raise ValueError(
77
+ "Cannot render locally — this template uses EXTERNAL mode. "
78
+ "Pass the template to the provider directly."
79
+ )
80
+ try:
81
+ return Template(self.body).substitute(context)
82
+ except KeyError as e:
83
+ raise ValueError(f"Missing template variable: {e}") from e
84
+
85
+ def render_subject(self, context: dict[str, Any]) -> str | None:
86
+ if not self.subject:
87
+ return None
88
+ try:
89
+ return Template(self.subject).substitute(context)
90
+ except KeyError as e:
91
+ raise ValueError(f"Missing subject variable: {e}") from e
92
+
93
+ def translate_variables(self, context: dict[str, Any]) -> dict[str, Any]:
94
+ """Translate context to provider-expected variable names."""
95
+ return self.variable_mapping.translate(context)
File without changes
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class InfrastructureConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "notifyfork.core.infrastructure"
7
+ label = "notifyfork"
@@ -0,0 +1,3 @@
1
+ from notifyfork.core.infrastructure.container.providers import Container
2
+
3
+ __all__ = ["Container"]
@@ -0,0 +1,218 @@
1
+ """
2
+ Dependency container for NotifyFork.
3
+
4
+ Reads environment variables and wires everything together.
5
+ One place to look when adding a new provider or changing config.
6
+
7
+ Usage:
8
+ from notifyfork.core.infrastructure.container.providers import Container
9
+
10
+ use_case = Container.send_notification_use_case()
11
+ await use_case.execute(dto)
12
+ """
13
+ import os
14
+ import logging
15
+ from functools import lru_cache
16
+ from typing import Any
17
+
18
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider
19
+ from notifyfork.core.application.use_cases.send_notification import SendNotificationUseCase
20
+ from notifyfork.core.infrastructure.repositories.notification_repository import DjangoNotificationRepository
21
+ from notifyfork.core.infrastructure.repositories.template_repository import DatabaseTemplateRepository
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ DEFAULT_PROVIDER_ORDER = [
26
+ "twilio_sms",
27
+ "twilio_whatsapp",
28
+ "sendgrid_email",
29
+ "resend_email",
30
+ "smtp_email",
31
+ "firebase_push",
32
+ "slack",
33
+ ]
34
+
35
+ # Providers registered from outside the lib via @notifyfork.provider.
36
+ # Duck-typed on purpose — no NotificationProvider subclassing required,
37
+ # same as every built-in provider is only ever used through .name /
38
+ # .send_with_template() / .supported_channels, never isinstance-checked.
39
+ _extra_providers: list[Any] = []
40
+
41
+
42
+ def _ordered(providers: list[NotificationProvider]) -> list[NotificationProvider]:
43
+ """
44
+ Orders providers for fallback. When more than one provider supports the
45
+ same channel (e.g. SendGrid + SMTP for email), SendNotificationUseCase
46
+ tries them in this order — first one that succeeds wins.
47
+
48
+ Override with NOTIFYFORK_PROVIDER_ORDER="sendgrid_email,smtp_email"
49
+ (comma-separated provider names). Anything registered but not listed
50
+ keeps its default position, appended after the ones you did list.
51
+ """
52
+ raw = os.environ.get("NOTIFYFORK_PROVIDER_ORDER")
53
+ order = [name.strip() for name in raw.split(",")] if raw else DEFAULT_PROVIDER_ORDER
54
+
55
+ by_name = {p.name: p for p in providers}
56
+ ordered = [by_name[name] for name in order if name in by_name]
57
+ remaining = [p for p in providers if p.name not in order]
58
+ return ordered + remaining
59
+
60
+
61
+ def _build_providers() -> list[NotificationProvider]:
62
+ """
63
+ Instantiates only the providers that have credentials configured.
64
+ Missing credentials → provider is skipped with a warning.
65
+ This lets you run locally with only the providers you need.
66
+ """
67
+ providers = []
68
+
69
+ # Twilio SMS
70
+ twilio_sid = os.environ.get("TWILIO_ACCOUNT_SID")
71
+ twilio_token = os.environ.get("TWILIO_AUTH_TOKEN")
72
+ twilio_from = os.environ.get("TWILIO_FROM_NUMBER")
73
+
74
+ if all([twilio_sid, twilio_token, twilio_from]):
75
+ from notifyfork.core.infrastructure.providers.twilio_provider import TwilioSMSProvider
76
+ providers.append(TwilioSMSProvider(twilio_sid, twilio_token, twilio_from))
77
+ logger.info("Provider registered: twilio_sms")
78
+ else:
79
+ logger.warning("twilio_sms skipped — missing TWILIO_ACCOUNT_SID / AUTH_TOKEN / FROM_NUMBER")
80
+
81
+ # Twilio WhatsApp
82
+ twilio_wa_from = os.environ.get("TWILIO_WHATSAPP_FROM_NUMBER")
83
+
84
+ if all([twilio_sid, twilio_token, twilio_wa_from]):
85
+ from notifyfork.core.infrastructure.providers.whatsapp_provider import TwilioWhatsAppProvider
86
+ providers.append(TwilioWhatsAppProvider(twilio_sid, twilio_token, twilio_wa_from))
87
+ logger.info("Provider registered: twilio_whatsapp")
88
+ else:
89
+ logger.warning("twilio_whatsapp skipped — missing TWILIO_WHATSAPP_FROM_NUMBER")
90
+
91
+ # SendGrid Email
92
+ sendgrid_key = os.environ.get("SENDGRID_API_KEY")
93
+ sendgrid_from = os.environ.get("SENDGRID_FROM_EMAIL")
94
+ sendgrid_name = os.environ.get("SENDGRID_FROM_NAME", "NotifyFork")
95
+
96
+ if all([sendgrid_key, sendgrid_from]):
97
+ from notifyfork.core.infrastructure.providers.sendgrid_provider import SendGridEmailProvider
98
+ providers.append(SendGridEmailProvider(sendgrid_key, sendgrid_from, sendgrid_name))
99
+ logger.info("Provider registered: sendgrid_email")
100
+ else:
101
+ logger.warning("sendgrid_email skipped — missing SENDGRID_API_KEY / FROM_EMAIL")
102
+
103
+ # Resend Email
104
+ resend_key = os.environ.get("RESEND_API_KEY")
105
+ resend_from = os.environ.get("RESEND_FROM_EMAIL")
106
+ resend_name = os.environ.get("RESEND_FROM_NAME", "")
107
+
108
+ if all([resend_key, resend_from]):
109
+ from notifyfork.core.infrastructure.providers.resend_provider import ResendEmailProvider
110
+ providers.append(ResendEmailProvider(resend_key, resend_from, resend_name))
111
+ logger.info("Provider registered: resend_email")
112
+ else:
113
+ logger.warning("resend_email skipped — missing RESEND_API_KEY / FROM_EMAIL")
114
+
115
+ # SMTP Email (fallback)
116
+ smtp_host = os.environ.get("SMTP_HOST")
117
+ smtp_user = os.environ.get("SMTP_USERNAME")
118
+ smtp_pass = os.environ.get("SMTP_PASSWORD")
119
+ smtp_from = os.environ.get("SMTP_FROM_EMAIL")
120
+
121
+ if all([smtp_host, smtp_user, smtp_pass, smtp_from]):
122
+ from notifyfork.core.infrastructure.providers.smtp_provider import SMTPEmailProvider
123
+ providers.append(SMTPEmailProvider(
124
+ host=smtp_host,
125
+ port=int(os.environ.get("SMTP_PORT", "465")),
126
+ username=smtp_user,
127
+ password=smtp_pass,
128
+ from_email=smtp_from,
129
+ ))
130
+ logger.info("Provider registered: smtp_email")
131
+ else:
132
+ logger.warning("smtp_email skipped — missing SMTP_HOST / USERNAME / PASSWORD / FROM_EMAIL")
133
+
134
+ # Firebase Push
135
+ firebase_creds = os.environ.get("FIREBASE_CREDENTIALS_PATH")
136
+
137
+ if firebase_creds:
138
+ from notifyfork.core.infrastructure.providers.firebase_provider import FirebasePushProvider
139
+ providers.append(FirebasePushProvider())
140
+ logger.info("Provider registered: firebase_push")
141
+ else:
142
+ logger.warning("firebase_push skipped — missing FIREBASE_CREDENTIALS_PATH")
143
+
144
+ # Slack
145
+ slack_token = os.environ.get("SLACK_BOT_TOKEN")
146
+
147
+ if slack_token:
148
+ from notifyfork.core.infrastructure.providers.slack_provider import SlackProvider
149
+ providers.append(SlackProvider(bot_token=slack_token))
150
+ logger.info("Provider registered: slack")
151
+ else:
152
+ logger.warning("slack skipped — missing SLACK_BOT_TOKEN")
153
+
154
+ providers.extend(_extra_providers)
155
+
156
+ if not providers:
157
+ logger.error("No providers registered — check your environment variables")
158
+
159
+ return _ordered(providers)
160
+
161
+
162
+ class Container:
163
+ """
164
+ Simple service locator. Not a full DI framework on purpose —
165
+ keeps the dependency graph explicit and easy to follow.
166
+ """
167
+
168
+ @staticmethod
169
+ @lru_cache(maxsize=1)
170
+ def providers() -> list[NotificationProvider]:
171
+ return _build_providers()
172
+
173
+ @staticmethod
174
+ def notification_repository() -> DjangoNotificationRepository:
175
+ return DjangoNotificationRepository()
176
+
177
+ @staticmethod
178
+ def template_repository() -> DatabaseTemplateRepository:
179
+ return DatabaseTemplateRepository()
180
+
181
+ @staticmethod
182
+ def send_notification_use_case() -> SendNotificationUseCase:
183
+ return SendNotificationUseCase(
184
+ repository=Container.notification_repository(),
185
+ template_repository=Container.template_repository(),
186
+ providers=Container.providers(),
187
+ )
188
+
189
+
190
+ def provider(provider_cls: type) -> type:
191
+ """
192
+ Decorator that registers a custom provider from outside the lib.
193
+
194
+ No need to subclass NotificationProvider — duck-typing, same as every
195
+ built-in provider (the container only ever reads .name and calls
196
+ .send_with_template()). Just instantiate-and-go:
197
+
198
+ import notifyfork
199
+
200
+ @notifyfork.provider
201
+ class XptoProvider:
202
+ name = "xpto"
203
+ def send_with_template(self, recipient, template, context): ...
204
+
205
+ The class is instantiated with no arguments, so anything it needs
206
+ (API keys, clients, etc.) should be read in __init__ from the
207
+ environment, same as the built-in providers do in _build_providers().
208
+ """
209
+ instance = provider_cls()
210
+ _extra_providers.append(instance)
211
+
212
+ # Container.providers() is lru_cache'd — if something already read it
213
+ # before this decorator ran, drop the stale result so the new provider
214
+ # is picked up on the next call.
215
+ Container.providers.cache_clear()
216
+
217
+ logger.info("Provider registered: %s", getattr(instance, "name", provider_cls.__name__))
218
+ return provider_cls
@@ -0,0 +1,48 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+
6
+ initial = True
7
+
8
+ dependencies = []
9
+
10
+ operations = [
11
+ migrations.CreateModel(
12
+ name="NotificationModel",
13
+ fields=[
14
+ ("id", models.UUIDField(primary_key=True, editable=False)),
15
+ ("recipient", models.CharField(max_length=255)),
16
+ ("channel", models.CharField(max_length=20)),
17
+ ("notification_type", models.CharField(max_length=20)),
18
+ ("template_id", models.CharField(max_length=100)),
19
+ ("context", models.JSONField(default=dict)),
20
+ ("status", models.CharField(max_length=20, default="pending", db_index=True)),
21
+ ("provider_used", models.CharField(max_length=50, null=True, blank=True)),
22
+ ("attempts", models.PositiveSmallIntegerField(default=0)),
23
+ ("max_attempts", models.PositiveSmallIntegerField(default=3)),
24
+ ("error_detail", models.TextField(null=True, blank=True)),
25
+ ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
26
+ ("sent_at", models.DateTimeField(null=True, blank=True)),
27
+ ],
28
+ options={"db_table": "notifyfork_notifications", "ordering": ["-created_at"]},
29
+ ),
30
+ migrations.AddIndex(
31
+ model_name="NotificationModel",
32
+ index=models.Index(fields=["status", "attempts"], name="idx_status_attempts"),
33
+ ),
34
+ migrations.CreateModel(
35
+ name="NotificationTemplateModel",
36
+ fields=[
37
+ ("id", models.CharField(max_length=100, primary_key=True)),
38
+ ("body", models.TextField()),
39
+ ("subject", models.CharField(max_length=255, null=True, blank=True)),
40
+ ("mode", models.CharField(max_length=10, default="local")),
41
+ ("variable_mapping", models.JSONField(default=dict)),
42
+ ("is_active", models.BooleanField(default=True)),
43
+ ("created_at", models.DateTimeField(auto_now_add=True)),
44
+ ("updated_at", models.DateTimeField(auto_now=True)),
45
+ ],
46
+ options={"db_table": "notifyfork_templates"},
47
+ ),
48
+ ]