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.
- codex_platform/__init__.py +1 -0
- codex_platform/notifications/__init__.py +48 -0
- codex_platform/notifications/channels.py +24 -0
- codex_platform/notifications/clients/__init__.py +3 -0
- codex_platform/notifications/clients/smtp.py +134 -0
- codex_platform/notifications/delivery/__init__.py +18 -0
- codex_platform/notifications/delivery/arq.py +69 -0
- codex_platform/notifications/delivery/base.py +39 -0
- codex_platform/notifications/delivery/direct.py +89 -0
- codex_platform/notifications/dto.py +63 -0
- codex_platform/notifications/interfaces.py +30 -0
- codex_platform/notifications/orchestrator.py +116 -0
- codex_platform/notifications/registry.py +71 -0
- codex_platform/notifications/renderer.py +111 -0
- codex_platform/redis_service/__init__.py +59 -0
- codex_platform/redis_service/base.py +69 -0
- codex_platform/redis_service/exceptions.py +20 -0
- codex_platform/redis_service/keys.py +110 -0
- codex_platform/redis_service/managers/__init__.py +18 -0
- codex_platform/redis_service/managers/base_manager.py +31 -0
- codex_platform/redis_service/operations/__init__.py +19 -0
- codex_platform/redis_service/operations/hash.py +302 -0
- codex_platform/redis_service/operations/json_module.py +129 -0
- codex_platform/redis_service/operations/json_string.py +113 -0
- codex_platform/redis_service/operations/list_.py +219 -0
- codex_platform/redis_service/operations/pipeline.py +197 -0
- codex_platform/redis_service/operations/set_.py +212 -0
- codex_platform/redis_service/operations/string.py +318 -0
- codex_platform/redis_service/operations/zset.py +277 -0
- codex_platform/redis_service/service.py +80 -0
- codex_platform/streams/__init__.py +59 -0
- codex_platform/streams/consumer.py +106 -0
- codex_platform/streams/dispatcher.py +175 -0
- codex_platform/streams/processor.py +217 -0
- codex_platform/streams/producer.py +60 -0
- codex_platform/streams/router.py +78 -0
- codex_platform/workers/__init__.py +1 -0
- codex_platform/workers/arq/__init__.py +27 -0
- codex_platform/workers/arq/base.py +170 -0
- codex_platform/workers/arq/config.py +67 -0
- codex_platform/workers/arq/task_utils.py +72 -0
- codex_platform/workers/arq/types.py +11 -0
- codex_platform-0.1.0.dist-info/METADATA +120 -0
- codex_platform-0.1.0.dist-info/RECORD +45 -0
- 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,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
|