codex-platform 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.
Files changed (45) hide show
  1. codex_platform/__init__.py +1 -0
  2. codex_platform/notifications/__init__.py +48 -0
  3. codex_platform/notifications/channels.py +24 -0
  4. codex_platform/notifications/clients/__init__.py +3 -0
  5. codex_platform/notifications/clients/smtp.py +134 -0
  6. codex_platform/notifications/delivery/__init__.py +18 -0
  7. codex_platform/notifications/delivery/arq.py +69 -0
  8. codex_platform/notifications/delivery/base.py +39 -0
  9. codex_platform/notifications/delivery/direct.py +89 -0
  10. codex_platform/notifications/dto.py +63 -0
  11. codex_platform/notifications/interfaces.py +30 -0
  12. codex_platform/notifications/orchestrator.py +116 -0
  13. codex_platform/notifications/registry.py +71 -0
  14. codex_platform/notifications/renderer.py +111 -0
  15. codex_platform/redis_service/__init__.py +59 -0
  16. codex_platform/redis_service/base.py +69 -0
  17. codex_platform/redis_service/exceptions.py +20 -0
  18. codex_platform/redis_service/keys.py +110 -0
  19. codex_platform/redis_service/managers/__init__.py +18 -0
  20. codex_platform/redis_service/managers/base_manager.py +31 -0
  21. codex_platform/redis_service/operations/__init__.py +19 -0
  22. codex_platform/redis_service/operations/hash.py +302 -0
  23. codex_platform/redis_service/operations/json_module.py +129 -0
  24. codex_platform/redis_service/operations/json_string.py +113 -0
  25. codex_platform/redis_service/operations/list_.py +219 -0
  26. codex_platform/redis_service/operations/pipeline.py +197 -0
  27. codex_platform/redis_service/operations/set_.py +212 -0
  28. codex_platform/redis_service/operations/string.py +318 -0
  29. codex_platform/redis_service/operations/zset.py +277 -0
  30. codex_platform/redis_service/service.py +80 -0
  31. codex_platform/streams/__init__.py +59 -0
  32. codex_platform/streams/consumer.py +106 -0
  33. codex_platform/streams/dispatcher.py +175 -0
  34. codex_platform/streams/processor.py +217 -0
  35. codex_platform/streams/producer.py +60 -0
  36. codex_platform/streams/router.py +78 -0
  37. codex_platform/workers/__init__.py +1 -0
  38. codex_platform/workers/arq/__init__.py +27 -0
  39. codex_platform/workers/arq/base.py +170 -0
  40. codex_platform/workers/arq/config.py +67 -0
  41. codex_platform/workers/arq/task_utils.py +72 -0
  42. codex_platform/workers/arq/types.py +11 -0
  43. codex_platform-0.1.0.dist-info/METADATA +120 -0
  44. codex_platform-0.1.0.dist-info/RECORD +45 -0
  45. codex_platform-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1 @@
1
+ """codex-platform: Infrastructure, background tasks (ARQ), and framework adapters."""
@@ -0,0 +1,48 @@
1
+ """
2
+ codex_platform.notifications
3
+ =============================
4
+ Building blocks for notification delivery.
5
+
6
+ # Contracts / DTOs
7
+ from codex_platform.notifications import NotificationPayloadDTO, NotificationRecipient
8
+ from codex_platform.notifications import NotificationChannel
9
+
10
+ # Channel pipeline
11
+ from codex_platform.notifications import AsyncEmailClient
12
+ from codex_platform.notifications import BaseDeliveryOrchestrator, ChannelRegistry
13
+
14
+ # Delivery adapters (how payload gets to the pipeline)
15
+ from codex_platform.notifications.delivery import ArqNotificationAdapter
16
+ from codex_platform.notifications.delivery import DirectNotificationAdapter
17
+
18
+ # Template rendering (requires jinja2 — install separately)
19
+ from codex_platform.notifications.renderer import TemplateRenderer
20
+ """
21
+
22
+ from .channels import NotificationChannel
23
+ from .clients.smtp import AsyncEmailClient
24
+ from .delivery import ArqNotificationAdapter, DirectNotificationAdapter, NotificationAdapter
25
+ from .dto import NotificationPayloadDTO, NotificationRecipient, RenderedNotificationDTO, TemplateNotificationDTO
26
+ from .interfaces import ContentCacheAdapter, ContentProvider
27
+ from .orchestrator import BaseDeliveryOrchestrator, DeliveryChannel
28
+ from .registry import ChannelRegistry
29
+
30
+ __all__ = [
31
+ # Contracts
32
+ "NotificationChannel",
33
+ "NotificationPayloadDTO",
34
+ "TemplateNotificationDTO",
35
+ "RenderedNotificationDTO",
36
+ "NotificationRecipient",
37
+ "ContentProvider",
38
+ "ContentCacheAdapter",
39
+ "DeliveryChannel",
40
+ # Channel pipeline
41
+ "AsyncEmailClient",
42
+ "BaseDeliveryOrchestrator",
43
+ "ChannelRegistry",
44
+ # Delivery adapters
45
+ "NotificationAdapter",
46
+ "ArqNotificationAdapter",
47
+ "DirectNotificationAdapter",
48
+ ]
@@ -0,0 +1,24 @@
1
+ """
2
+ codex_platform.notifications.channels
3
+ =======================================
4
+ Enum of supported notification delivery channels.
5
+ """
6
+
7
+ import sys
8
+ from enum import Enum
9
+
10
+ if sys.version_info >= (3, 11):
11
+ from enum import StrEnum
12
+ else:
13
+ # Fallback for Python 3.10
14
+ class StrEnum(str, Enum):
15
+ pass
16
+
17
+
18
+ class NotificationChannel(StrEnum):
19
+ """Supported notification delivery channel identifiers."""
20
+
21
+ EMAIL = "email"
22
+ TELEGRAM = "telegram"
23
+ SMS = "sms"
24
+ WHATSAPP = "whatsapp"
@@ -0,0 +1,3 @@
1
+ from .smtp import AsyncEmailClient
2
+
3
+ __all__ = ["AsyncEmailClient"]
@@ -0,0 +1,134 @@
1
+ """
2
+ codex_platform.workers.notifications.email
3
+ ========================================
4
+ Async SMTP email client — implements ``DeliveryChannel``.
5
+
6
+ Creates persistent SMTP connection config at init time.
7
+ For multi-tenant: create separate instances with different settings.
8
+
9
+ Requires: pip install codex-tools[notifications]
10
+ """
11
+
12
+ import logging
13
+ from email.message import EmailMessage
14
+ from typing import Any
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+ try:
19
+ import aiosmtplib
20
+
21
+ _AIOSMTP_AVAILABLE = True
22
+ except ImportError as e:
23
+ if e.name == "aiosmtplib":
24
+ _AIOSMTP_AVAILABLE = False
25
+ else:
26
+ raise
27
+
28
+
29
+ class AsyncEmailClient:
30
+ """
31
+ Async SMTP email sender. Implements ``DeliveryChannel``.
32
+
33
+ Usage:
34
+ client = AsyncEmailClient(
35
+ smtp_host="mail.example.com", smtp_port=465,
36
+ smtp_user="user", smtp_password="pass", # pragma: allowlist secret
37
+ smtp_from_email="noreply@example.com",
38
+ )
39
+ success = await client.send(
40
+ to="user@example.com",
41
+ subject="Hello",
42
+ html_content="<b>Hi</b>",
43
+ text_content="Hi",
44
+ )
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ smtp_host: str,
50
+ smtp_port: int,
51
+ smtp_user: str | None = None,
52
+ smtp_password: str | None = None, # pragma: allowlist secret
53
+ smtp_from_email: str | None = None,
54
+ smtp_use_tls: bool = False,
55
+ ) -> None:
56
+ self.smtp_host = smtp_host
57
+ self.smtp_port = smtp_port
58
+ self.smtp_user = smtp_user
59
+ self.smtp_password = smtp_password
60
+ self.smtp_from_email = smtp_from_email
61
+ self.smtp_use_tls = smtp_use_tls
62
+
63
+ def is_available(self) -> bool:
64
+ """Returns True if the client is configured (non-localhost host)."""
65
+ return bool(self.smtp_host and self.smtp_host != "localhost")
66
+
67
+ async def send(
68
+ self,
69
+ to: str,
70
+ subject: str,
71
+ html_content: str | None,
72
+ text_content: str | None,
73
+ timeout: int = 15,
74
+ ) -> bool:
75
+ """
76
+ Send email via SMTP.
77
+
78
+ Args:
79
+ to: Recipient email address.
80
+ subject: Email subject line.
81
+ html_content: HTML body (preferred). Used as HTML part.
82
+ text_content: Plain-text body (fallback for HTML-disabled clients).
83
+ If None and html_content is provided, a generic fallback
84
+ is used.
85
+ timeout: SMTP connection timeout in seconds.
86
+
87
+ Returns:
88
+ True on success.
89
+
90
+ Raises:
91
+ RuntimeError: If aiosmtplib is not installed.
92
+ aiosmtplib.SMTPException: On SMTP delivery failure.
93
+ """
94
+ if not _AIOSMTP_AVAILABLE:
95
+ raise RuntimeError("aiosmtplib not installed. Run: pip install codex-tools[notifications]")
96
+ await self._send_smtp(to, subject, html_content, text_content, timeout)
97
+ log.info("AsyncEmailClient | sent to='%s'", to)
98
+ return True
99
+
100
+ async def _send_smtp(
101
+ self,
102
+ to: str,
103
+ subject: str,
104
+ html_content: str | None,
105
+ text_content: str | None,
106
+ timeout: int,
107
+ ) -> None:
108
+ message = EmailMessage()
109
+ message["From"] = self.smtp_from_email
110
+ message["To"] = to
111
+ message["Subject"] = subject
112
+
113
+ plain = text_content or "Please enable HTML to view this email."
114
+ message.set_content(plain)
115
+
116
+ if html_content:
117
+ message.add_alternative(html_content, subtype="html")
118
+
119
+ use_ssl = self.smtp_port == 465
120
+ start_tls = self.smtp_port == 587 or (self.smtp_use_tls and self.smtp_port != 465)
121
+
122
+ send_kwargs: dict[str, Any] = {
123
+ "hostname": self.smtp_host,
124
+ "port": self.smtp_port,
125
+ "use_tls": use_ssl,
126
+ "start_tls": start_tls,
127
+ "timeout": timeout,
128
+ }
129
+
130
+ if self.smtp_user and self.smtp_password:
131
+ send_kwargs["username"] = self.smtp_user
132
+ send_kwargs["password"] = self.smtp_password
133
+
134
+ await aiosmtplib.send(message, **send_kwargs)
@@ -0,0 +1,18 @@
1
+ """
2
+ codex_platform.notifications.delivery
3
+ =======================================
4
+ Delivery adapters — the "how" of getting a notification to its destination.
5
+
6
+ - ``ArqNotificationAdapter`` → enqueues into Redis/ARQ queue
7
+ - ``DirectNotificationAdapter`` → delivers in-process (sync, no broker)
8
+ """
9
+
10
+ from .arq import ArqNotificationAdapter
11
+ from .base import NotificationAdapter
12
+ from .direct import DirectNotificationAdapter
13
+
14
+ __all__ = [
15
+ "NotificationAdapter",
16
+ "ArqNotificationAdapter",
17
+ "DirectNotificationAdapter",
18
+ ]
@@ -0,0 +1,69 @@
1
+ """
2
+ codex_platform.notifications.delivery.arq
3
+ ==========================================
4
+ ARQ delivery adapter — enqueues notification tasks into Redis/ARQ queue.
5
+
6
+ Example::
7
+
8
+ from arq.connections import create_pool, RedisSettings
9
+ from codex_platform.notifications.delivery.arq import ArqNotificationAdapter
10
+
11
+ pool = await create_pool(RedisSettings())
12
+ adapter = ArqNotificationAdapter(pool)
13
+
14
+ # Async context (preferred):
15
+ job_id = await adapter.enqueue_async("send_notification_task", payload)
16
+
17
+ # Sync context:
18
+ job_id = adapter.enqueue("send_notification_task", payload)
19
+ """
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ from arq.connections import ArqRedis
25
+
26
+ from .base import NotificationAdapter
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ class ArqNotificationAdapter(NotificationAdapter):
32
+ """
33
+ Notification adapter that enqueues tasks into an ARQ/Redis queue.
34
+
35
+ Args:
36
+ pool: ``ArqRedis`` connection pool from ``arq.connections.create_pool``.
37
+
38
+ Raises on infrastructure failures (Redis unavailable, serialization errors).
39
+ """
40
+
41
+ def __init__(self, pool: ArqRedis) -> None:
42
+ self.pool = pool
43
+
44
+ def enqueue(self, task_name: str, payload: dict[str, Any]) -> str | None:
45
+ """Sync enqueue — wraps async via asyncio. Use enqueue_async in async contexts."""
46
+ import asyncio
47
+
48
+ return asyncio.get_event_loop().run_until_complete(self.enqueue_async(task_name, payload))
49
+
50
+ async def enqueue_async(self, task_name: str, payload: dict[str, Any]) -> str | None:
51
+ """
52
+ Enqueue a notification task into the ARQ queue (async).
53
+
54
+ Args:
55
+ task_name: ARQ worker function name (e.g. ``'send_notification_task'``).
56
+ payload: Serialized ``NotificationPayloadDTO``.
57
+
58
+ Returns:
59
+ Job ID string, or None if ARQ returned no handle.
60
+
61
+ Raises:
62
+ ConnectionError: Redis is unreachable.
63
+ Exception: Any ARQ/Redis infrastructure failure.
64
+ """
65
+ log.debug("ArqNotificationAdapter | enqueueing task=%s", task_name)
66
+ job = await self.pool.enqueue_job(task_name, payload_dict=payload)
67
+ job_id = getattr(job, "job_id", None)
68
+ log.info("ArqNotificationAdapter | task=%s job_id=%s", task_name, job_id)
69
+ return job_id
@@ -0,0 +1,39 @@
1
+ """
2
+ codex_platform.notifications.delivery.base
3
+ ==========================================
4
+ Protocol definition for notification delivery adapters.
5
+ """
6
+
7
+ from typing import Any, Protocol
8
+
9
+
10
+ class NotificationAdapter(Protocol):
11
+ """
12
+ Contract for notification delivery transport.
13
+
14
+ Allows the same business logic to work with ARQ, Celery, Direct calls,
15
+ or Django's built-in mail system.
16
+
17
+ Implementations MUST:
18
+ - Raise exceptions on infrastructure failures (network, broker, DB).
19
+ - Return a job/task ID (str) when the backend provides one.
20
+ - Return None for fire-and-forget transports with no tracking ID.
21
+ """
22
+
23
+ def enqueue(self, task_name: str, payload: dict[str, Any]) -> str | None:
24
+ """
25
+ Deliver or enqueue the notification.
26
+
27
+ Args:
28
+ task_name: Worker function name to execute.
29
+ May be unused by adapters that deliver synchronously.
30
+ payload: Serialized ``NotificationPayloadDTO`` (via ``.model_dump(mode="json")``).
31
+
32
+ Returns:
33
+ str: Job/task identifier for tracking.
34
+ None: When the transport is fire-and-forget.
35
+
36
+ Raises:
37
+ Exception: Infrastructure errors MUST propagate — never swallow them.
38
+ """
39
+ ...
@@ -0,0 +1,89 @@
1
+ """
2
+ codex_platform.notifications.delivery.direct
3
+ =============================================
4
+ Direct delivery adapter — delivers notifications synchronously in-process.
5
+
6
+ Uses the full channel pipeline (ChannelRegistry → BaseDeliveryOrchestrator)
7
+ without a message broker.
8
+
9
+ .. warning::
10
+
11
+ Calls ``asyncio.run()`` — MUST NOT be used inside a running event loop
12
+ (ASGI views, async handlers). Use ``ArqNotificationAdapter`` instead.
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+ from typing import Any
18
+
19
+ from .base import NotificationAdapter
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ class DirectNotificationAdapter(NotificationAdapter):
25
+ """
26
+ Adapter for synchronous/monolithic notification delivery.
27
+
28
+ Injects a pre-built list of channels (via ``ChannelRegistry``) into
29
+ ``BaseDeliveryOrchestrator`` and runs the async pipeline synchronously.
30
+
31
+ Uses lazy imports to avoid circular dependencies.
32
+ """
33
+
34
+ def __init__(self, config: Any) -> None:
35
+ self.config = config
36
+
37
+ def enqueue(self, _task_name: str, payload: dict[str, Any]) -> str | None:
38
+ """
39
+ Deliver a notification synchronously via the orchestrator pipeline.
40
+
41
+ Args:
42
+ _task_name: Unused — direct delivery runs in-process.
43
+ payload: Serialized ``NotificationPayloadDTO``.
44
+
45
+ Returns:
46
+ ``notification_id`` from payload on success, None if not present.
47
+
48
+ Raises:
49
+ RuntimeError: If called from within a running event loop.
50
+ Exception: Channel infrastructure failures propagate upward.
51
+ """
52
+ from codex_platform.notifications.dto import NotificationPayloadDTO
53
+ from codex_platform.notifications.orchestrator import BaseDeliveryOrchestrator
54
+ from codex_platform.notifications.registry import ChannelRegistry
55
+
56
+ log.debug("DirectNotificationAdapter | starting direct delivery")
57
+
58
+ registry = ChannelRegistry()
59
+ _register_default_channels(registry, self.config)
60
+ channels = registry.build_channels(self.config)
61
+ orchestrator = BaseDeliveryOrchestrator(channels=channels)
62
+
63
+ payload_dto = NotificationPayloadDTO(**payload)
64
+ asyncio.run(orchestrator.deliver(payload_dto))
65
+
66
+ notification_id = payload.get("notification_id")
67
+ log.info("DirectNotificationAdapter | delivered notification_id=%s", notification_id)
68
+ return notification_id
69
+
70
+
71
+ def _register_default_channels(registry: Any, config: Any) -> None:
72
+ """Register default delivery channels from config. Override by calling registry.register() directly."""
73
+ try:
74
+ from codex_platform.notifications.clients.smtp import AsyncEmailClient
75
+
76
+ smtp_host = getattr(config, "SMTP_HOST", "")
77
+ if smtp_host:
78
+ registry.register(
79
+ "smtp",
80
+ lambda cfg: AsyncEmailClient(
81
+ smtp_host=getattr(cfg, "SMTP_HOST", ""),
82
+ smtp_port=getattr(cfg, "SMTP_PORT", 587),
83
+ smtp_user=getattr(cfg, "SMTP_USER", None),
84
+ smtp_password=getattr(cfg, "SMTP_PASSWORD", None),
85
+ smtp_from_email=getattr(cfg, "SMTP_FROM_EMAIL", None),
86
+ ),
87
+ )
88
+ except ImportError:
89
+ pass
@@ -0,0 +1,63 @@
1
+ """
2
+ codex_platform.notifications.dto
3
+ ==================================
4
+ Notification payload contracts.
5
+
6
+ Three classes:
7
+
8
+ NotificationPayloadDTO — base, always required fields
9
+ TemplateNotificationDTO — Mode 1: workers fetches context from Redis, renders template
10
+ RenderedNotificationDTO — Mode 2: pre-rendered HTML (e.g. from Django)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from codex_core.core import BaseDTO
16
+
17
+ from .channels import NotificationChannel
18
+
19
+
20
+ class NotificationRecipient(BaseDTO): # type: ignore[misc]
21
+ """Recipient info. PII fields auto-masked in __repr__ via BaseDTO."""
22
+
23
+ email: str | None = None
24
+ phone: str | None = None
25
+
26
+
27
+ class NotificationPayloadDTO(BaseDTO): # type: ignore[misc]
28
+ """
29
+ Base notification payload — identification and routing only.
30
+
31
+ Do not use directly. Use TemplateNotificationDTO or RenderedNotificationDTO.
32
+ """
33
+
34
+ notification_id: str
35
+ recipient: NotificationRecipient
36
+ channels: list[NotificationChannel] = [NotificationChannel.EMAIL]
37
+ event_type: str | None = None
38
+ subject: str | None = None
39
+
40
+
41
+ class TemplateNotificationDTO(NotificationPayloadDTO):
42
+ """
43
+ Mode 1 — Worker renders the template itself (requires Jinja2).
44
+
45
+ context_key: Redis key where context_data is stored (JSON).
46
+ Allows updating data after enqueue (e.g. reschedule).
47
+ template_name: Relative template path (e.g. 'booking/bk_confirmation.html').
48
+ """
49
+
50
+ template_name: str
51
+ context_key: str
52
+
53
+
54
+ class RenderedNotificationDTO(NotificationPayloadDTO):
55
+ """
56
+ Mode 2 — Pre-rendered HTML passed directly to the workers.
57
+
58
+ Use when Django (or another layer) renders the template
59
+ and the workers only needs to deliver.
60
+ """
61
+
62
+ html_content: str
63
+ text_content: str | None = None
@@ -0,0 +1,30 @@
1
+ """
2
+ codex_platform.core.interfaces
3
+ ============================
4
+ Protocol contracts for core adapters.
5
+ The library core relies only on these interfaces, not on concrete ORM models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol
11
+
12
+
13
+ class ContentProvider(Protocol):
14
+ """Provides translated template text by key."""
15
+
16
+ def get_text(self, key: str) -> str | None:
17
+ """Return translated text or None if not found."""
18
+ ...
19
+
20
+
21
+ class ContentCacheAdapter(Protocol):
22
+ """Adapter for caching email/notification content (used by BaseEmailContentSelector)."""
23
+
24
+ def get_cached_value(self, key: str) -> str | None:
25
+ """Return cached string value or None if not found."""
26
+ ...
27
+
28
+ def set_cached_value(self, key: str, value: str, timeout: int) -> None:
29
+ """Store value in cache with given timeout (seconds)."""
30
+ ...
@@ -0,0 +1,116 @@
1
+ """
2
+ codex_platform.workers.notifications.orchestrator
3
+ ===============================================
4
+ Fallback delivery orchestrator.
5
+ Tries channels in order; stops on first success.
6
+
7
+ Usage:
8
+ registry = ChannelRegistry()
9
+ registry.register("smtp", lambda cfg: AsyncEmailClient(...))
10
+ channels = registry.build_channels(settings)
11
+
12
+ orchestrator = BaseDeliveryOrchestrator(channels=channels)
13
+ success = await orchestrator.deliver(payload_dto)
14
+ """
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
18
+
19
+ if TYPE_CHECKING:
20
+ from codex_platform.notifications.dto import NotificationPayloadDTO
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ @runtime_checkable
26
+ class DeliveryChannel(Protocol):
27
+ """
28
+ Contract for a single delivery method.
29
+
30
+ Implementors: AsyncEmailClient, DjangoMailChannel, TelegramChannel, etc.
31
+ """
32
+
33
+ async def send(
34
+ self,
35
+ to: str,
36
+ subject: str,
37
+ html_content: str | None,
38
+ text_content: str | None,
39
+ ) -> bool:
40
+ """
41
+ Attempt delivery. Return True on success, False on logical failure.
42
+ Raise on infrastructure errors (so the orchestrator can log and try next channel).
43
+ """
44
+ ...
45
+
46
+ def is_available(self) -> bool:
47
+ """Return True if this channel is properly configured and ready."""
48
+ ...
49
+
50
+
51
+ class BaseDeliveryOrchestrator:
52
+ """
53
+ Tries channels in order; stops on first success.
54
+
55
+ Channels are injected at construction time via Dependency Injection.
56
+ ``deliver()`` is a pure send operation with no side effects beyond delivery.
57
+ """
58
+
59
+ def __init__(self, channels: list[DeliveryChannel]) -> None:
60
+ """
61
+ Args:
62
+ channels: Ordered list of delivery channels to try.
63
+ Use ``ChannelRegistry.build_channels()`` to build this list.
64
+ """
65
+ self.channels = channels
66
+
67
+ async def deliver(self, payload: "NotificationPayloadDTO") -> bool:
68
+ """
69
+ Deliver a notification payload through available channels.
70
+
71
+ Tries each channel in order; stops on first success.
72
+ If a channel raises, logs the exception and falls through to the next.
73
+
74
+ Args:
75
+ payload: ``NotificationPayloadDTO`` with ready-made html/text content.
76
+
77
+ Returns:
78
+ True if at least one channel succeeded, False if all exhausted.
79
+ """
80
+ to = payload.recipient.email or payload.recipient.phone or ""
81
+ subject = payload.subject or ""
82
+
83
+ if not to:
84
+ log.error(
85
+ "DeliveryOrchestrator | no recipient address, notification_id=%s",
86
+ payload.notification_id,
87
+ )
88
+ return False
89
+
90
+ for channel in self.channels:
91
+ if not channel.is_available():
92
+ continue
93
+ try:
94
+ if await channel.send(
95
+ to=to,
96
+ subject=subject,
97
+ html_content=getattr(payload, "html_content", None),
98
+ text_content=getattr(payload, "text_content", None),
99
+ ):
100
+ log.info(
101
+ "DeliveryOrchestrator | delivered via %s, notification_id=%s",
102
+ type(channel).__name__,
103
+ payload.notification_id,
104
+ )
105
+ return True
106
+ except Exception:
107
+ log.exception(
108
+ "DeliveryOrchestrator | channel=%s failed, trying next",
109
+ type(channel).__name__,
110
+ )
111
+
112
+ log.error(
113
+ "DeliveryOrchestrator | all channels exhausted, notification_id=%s",
114
+ payload.notification_id,
115
+ )
116
+ return False