iil-platform-notifications 0.1.0__tar.gz

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 (25) hide show
  1. iil_platform_notifications-0.1.0/.gitignore +50 -0
  2. iil_platform_notifications-0.1.0/PKG-INFO +79 -0
  3. iil_platform_notifications-0.1.0/README.md +60 -0
  4. iil_platform_notifications-0.1.0/platform_notifications/__init__.py +7 -0
  5. iil_platform_notifications-0.1.0/platform_notifications/apps.py +33 -0
  6. iil_platform_notifications-0.1.0/platform_notifications/channels/__init__.py +1 -0
  7. iil_platform_notifications-0.1.0/platform_notifications/channels/base.py +58 -0
  8. iil_platform_notifications-0.1.0/platform_notifications/channels/discord.py +64 -0
  9. iil_platform_notifications-0.1.0/platform_notifications/channels/email.py +49 -0
  10. iil_platform_notifications-0.1.0/platform_notifications/channels/sms.py +68 -0
  11. iil_platform_notifications-0.1.0/platform_notifications/channels/telegram.py +74 -0
  12. iil_platform_notifications-0.1.0/platform_notifications/channels/webhook.py +41 -0
  13. iil_platform_notifications-0.1.0/platform_notifications/migrations/0001_initial.py +123 -0
  14. iil_platform_notifications-0.1.0/platform_notifications/migrations/__init__.py +0 -0
  15. iil_platform_notifications-0.1.0/platform_notifications/models.py +63 -0
  16. iil_platform_notifications-0.1.0/platform_notifications/registry.py +70 -0
  17. iil_platform_notifications-0.1.0/platform_notifications/service.py +163 -0
  18. iil_platform_notifications-0.1.0/platform_notifications/tasks.py +101 -0
  19. iil_platform_notifications-0.1.0/pyproject.toml +40 -0
  20. iil_platform_notifications-0.1.0/tests/__init__.py +0 -0
  21. iil_platform_notifications-0.1.0/tests/conftest.py +11 -0
  22. iil_platform_notifications-0.1.0/tests/settings.py +19 -0
  23. iil_platform_notifications-0.1.0/tests/test_channels.py +116 -0
  24. iil_platform_notifications-0.1.0/tests/test_registry.py +89 -0
  25. iil_platform_notifications-0.1.0/tests/test_service.py +132 -0
@@ -0,0 +1,50 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *\.class
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ # Django
11
+ *.log
12
+ db.sqlite3
13
+ media/
14
+ staticfiles/
15
+ # Environment
16
+ .env
17
+ .env.local
18
+ .env.prod
19
+
20
+ # Secrets (ADR-045) — NEVER commit plaintext
21
+ secrets.env
22
+ !secrets.enc.env
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+ # Large files
31
+ *.sqlite3
32
+ *.log
33
+ output_batch/
34
+ app_backup/
35
+ docs_legacy/
36
+ # Sphinx build output
37
+ docs/_build/
38
+ *:Zone.Identifier
39
+ # Local Windsurf/MCP config — machine-specific, never commit
40
+ .windsurf/mcp_config.json
41
+ # Local infra plaintext secrets — NEVER commit
42
+ infra/*.env
43
+ infra/*.key
44
+ # Validate-ports CI draft (not yet integrated)
45
+ .github/workflows/validate-ports.yml
46
+ # Markdownlint local config override
47
+ .markdownlint.json
48
+ # Concept review drafts (local working copies, not for VCS)
49
+ docs/concepts/REVIEW-*
50
+ env_loader.py
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: iil-platform-notifications
3
+ Version: 0.1.0
4
+ Summary: Platform-wide multi-channel notification registry (ADR-088)
5
+ Author: Achim Dehnert
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: celery>=5.3
9
+ Requires-Dist: django>=4.2
10
+ Requires-Dist: httpx>=0.25
11
+ Requires-Dist: pydantic>=2.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-django>=4.5; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.4; extra == 'dev'
16
+ Provides-Extra: sms
17
+ Requires-Dist: twilio>=8.0; extra == 'sms'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # platform-notifications
21
+
22
+ > Platform-wide multi-channel notification registry (ADR-088)
23
+
24
+ ## Overview
25
+
26
+ Unified notification system with channel abstraction, Celery-First
27
+ async delivery, audit logging, and thread-safe registry pattern.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install -e packages/platform-notifications
33
+ pip install -e "packages/platform-notifications[sms]" # for Twilio SMS
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from platform_notifications.service import NotificationService, Notification
40
+
41
+ NotificationService.send(Notification(
42
+ tenant_id=request.tenant_id,
43
+ channel="email",
44
+ recipient="user@example.com",
45
+ subject="Welcome",
46
+ body="Hello from the platform!",
47
+ source_app="wedding-hub",
48
+ source_event="rsvp_confirmation",
49
+ ))
50
+ ```
51
+
52
+ ## Built-in Channels
53
+
54
+ - **email** — Django `send_mail`
55
+ - **sms** — Twilio (requires `twilio` extra)
56
+ - **webhook** — Generic HTTPS POST via `httpx`
57
+
58
+ ## Configuration
59
+
60
+ ```python
61
+ # config/settings/base.py
62
+ PLATFORM_NOTIFICATIONS = {
63
+ "DEFAULT_CHANNELS": ["email"],
64
+ "RETRY_MAX": 3,
65
+ "RETRY_BACKOFF": True,
66
+ "RETRY_BACKOFF_MAX": 300,
67
+ "LOG_RETENTION_DAYS": 90,
68
+ }
69
+ ```
70
+
71
+ ## Database
72
+
73
+ Run: `python manage.py migrate platform_notifications`
74
+
75
+ ## Related ADRs
76
+
77
+ - **ADR-088**: Notification Registry
78
+ - **ADR-045**: Secret Management (Twilio, Discord, Telegram)
79
+ - **ADR-072**: Schema Isolation (Row-Level deviation documented)
@@ -0,0 +1,60 @@
1
+ # platform-notifications
2
+
3
+ > Platform-wide multi-channel notification registry (ADR-088)
4
+
5
+ ## Overview
6
+
7
+ Unified notification system with channel abstraction, Celery-First
8
+ async delivery, audit logging, and thread-safe registry pattern.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install -e packages/platform-notifications
14
+ pip install -e "packages/platform-notifications[sms]" # for Twilio SMS
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from platform_notifications.service import NotificationService, Notification
21
+
22
+ NotificationService.send(Notification(
23
+ tenant_id=request.tenant_id,
24
+ channel="email",
25
+ recipient="user@example.com",
26
+ subject="Welcome",
27
+ body="Hello from the platform!",
28
+ source_app="wedding-hub",
29
+ source_event="rsvp_confirmation",
30
+ ))
31
+ ```
32
+
33
+ ## Built-in Channels
34
+
35
+ - **email** — Django `send_mail`
36
+ - **sms** — Twilio (requires `twilio` extra)
37
+ - **webhook** — Generic HTTPS POST via `httpx`
38
+
39
+ ## Configuration
40
+
41
+ ```python
42
+ # config/settings/base.py
43
+ PLATFORM_NOTIFICATIONS = {
44
+ "DEFAULT_CHANNELS": ["email"],
45
+ "RETRY_MAX": 3,
46
+ "RETRY_BACKOFF": True,
47
+ "RETRY_BACKOFF_MAX": 300,
48
+ "LOG_RETENTION_DAYS": 90,
49
+ }
50
+ ```
51
+
52
+ ## Database
53
+
54
+ Run: `python manage.py migrate platform_notifications`
55
+
56
+ ## Related ADRs
57
+
58
+ - **ADR-088**: Notification Registry
59
+ - **ADR-045**: Secret Management (Twilio, Discord, Telegram)
60
+ - **ADR-072**: Schema Isolation (Row-Level deviation documented)
@@ -0,0 +1,7 @@
1
+ """Platform-wide multi-channel notification registry (ADR-088).
2
+
3
+ Provides unified notification API with channel abstraction,
4
+ Celery-First async delivery, and audit logging.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,33 @@
1
+ """Django app configuration for platform-notifications."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class PlatformNotificationsConfig(AppConfig):
7
+ """Platform Notifications app config."""
8
+
9
+ name = "platform_notifications"
10
+ verbose_name = "Platform Notifications"
11
+ default_auto_field = "django.db.models.BigAutoField"
12
+
13
+ def ready(self) -> None:
14
+ """Register built-in channels on app startup."""
15
+ from platform_notifications.channels.email import EmailChannel
16
+ from platform_notifications.channels.webhook import WebhookChannel
17
+ from platform_notifications.registry import ChannelRegistry
18
+
19
+ registry = ChannelRegistry.get_instance()
20
+ registry.register(EmailChannel())
21
+ registry.register(WebhookChannel())
22
+
23
+ try:
24
+ from platform_notifications.channels.sms import SmsChannel
25
+ registry.register(SmsChannel())
26
+ except ImportError:
27
+ pass
28
+
29
+ from platform_notifications.channels.discord import DiscordChannel
30
+ from platform_notifications.channels.telegram import TelegramChannel
31
+
32
+ registry.register(DiscordChannel())
33
+ registry.register(TelegramChannel())
@@ -0,0 +1 @@
1
+ """Notification channel implementations."""
@@ -0,0 +1,58 @@
1
+ """Base channel interface for notification channels (ADR-088)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class ChannelConfig(BaseModel):
11
+ """Configuration for a notification channel."""
12
+
13
+ model_config = ConfigDict(frozen=True)
14
+
15
+ max_retries: int = Field(
16
+ default=3, description="Max delivery retries"
17
+ )
18
+ retry_backoff: bool = Field(
19
+ default=True, description="Exponential backoff"
20
+ )
21
+ retry_backoff_max: int = Field(
22
+ default=300, description="Max backoff seconds"
23
+ )
24
+ timeout: int = Field(
25
+ default=30, description="Delivery timeout seconds"
26
+ )
27
+
28
+
29
+ class BaseChannel(ABC):
30
+ """Abstract base for notification channels."""
31
+
32
+ name: str
33
+ config: ChannelConfig
34
+
35
+ def __init__(
36
+ self, config: ChannelConfig | None = None
37
+ ) -> None:
38
+ self.config = config or ChannelConfig()
39
+
40
+ @abstractmethod
41
+ def deliver(
42
+ self,
43
+ recipient: str,
44
+ subject: str,
45
+ body: str,
46
+ **kwargs: object,
47
+ ) -> bool:
48
+ """Deliver notification. Returns True on success."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ def validate_recipient(self, recipient: str) -> bool:
53
+ """Validate recipient format."""
54
+ ...
55
+
56
+ def health_check(self) -> dict[str, bool | str]:
57
+ """Check channel connectivity."""
58
+ return {"healthy": True, "channel": self.name}
@@ -0,0 +1,64 @@
1
+ """Discord webhook notification channel (ADR-088).
2
+
3
+ Requires: DISCORD_WEBHOOK_URL in Django settings (ADR-045).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ import httpx
11
+
12
+ from platform_notifications.channels.base import BaseChannel
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class DiscordChannel(BaseChannel):
18
+ """Discord notifications via webhook URL."""
19
+
20
+ name = "discord"
21
+
22
+ def deliver(
23
+ self,
24
+ recipient: str,
25
+ subject: str,
26
+ body: str,
27
+ **kwargs: object,
28
+ ) -> bool:
29
+ """Post message to Discord webhook.
30
+
31
+ recipient = webhook URL (per-channel or per-tenant).
32
+ """
33
+ content = f"**{subject}**\n{body}" if subject else body
34
+ with httpx.Client(timeout=self.config.timeout) as client:
35
+ response = client.post(
36
+ recipient,
37
+ json={"content": content[:2000]},
38
+ )
39
+ response.raise_for_status()
40
+ return True
41
+
42
+ def validate_recipient(self, recipient: str) -> bool:
43
+ """Validate Discord webhook URL format."""
44
+ return (
45
+ recipient.startswith("https://discord.com/api/webhooks/")
46
+ or recipient.startswith("https://discordapp.com/api/webhooks/")
47
+ )
48
+
49
+ def health_check(self) -> dict[str, bool | str]:
50
+ """Check Discord webhook URL is configured."""
51
+ try:
52
+ from django.conf import settings
53
+
54
+ url = getattr(settings, "DISCORD_WEBHOOK_URL", "")
55
+ return {
56
+ "healthy": bool(url),
57
+ "channel": self.name,
58
+ }
59
+ except Exception as exc:
60
+ return {
61
+ "healthy": False,
62
+ "channel": self.name,
63
+ "error": str(exc),
64
+ }
@@ -0,0 +1,49 @@
1
+ """Email notification channel (ADR-088)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from platform_notifications.channels.base import BaseChannel
6
+
7
+
8
+ class EmailChannel(BaseChannel):
9
+ """Email channel via Django send_mail."""
10
+
11
+ name = "email"
12
+
13
+ def deliver(
14
+ self,
15
+ recipient: str,
16
+ subject: str,
17
+ body: str,
18
+ **kwargs: object,
19
+ ) -> bool:
20
+ """Send email via Django mail backend."""
21
+ from django.core.mail import send_mail
22
+
23
+ send_mail(
24
+ subject,
25
+ body,
26
+ None,
27
+ [recipient],
28
+ fail_silently=False,
29
+ )
30
+ return True
31
+
32
+ def validate_recipient(self, recipient: str) -> bool:
33
+ """Validate email address format."""
34
+ from django.core.validators import validate_email
35
+
36
+ try:
37
+ validate_email(recipient)
38
+ return True
39
+ except Exception:
40
+ return False
41
+
42
+ def health_check(self) -> dict[str, bool | str]:
43
+ """Check Django email backend is configured."""
44
+ from django.conf import settings
45
+
46
+ has_backend = bool(
47
+ getattr(settings, "EMAIL_BACKEND", None)
48
+ )
49
+ return {"healthy": has_backend, "channel": self.name}
@@ -0,0 +1,68 @@
1
+ """SMS notification channel via Twilio (ADR-088).
2
+
3
+ Requires: pip install platform-notifications[sms]
4
+ Secrets: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER (ADR-045)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from platform_notifications.channels.base import BaseChannel
12
+
13
+ E164_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$")
14
+
15
+
16
+ class SmsChannel(BaseChannel):
17
+ """SMS via Twilio. Replaces wedding-hub direct Twilio calls."""
18
+
19
+ name = "sms"
20
+
21
+ def deliver(
22
+ self,
23
+ recipient: str,
24
+ subject: str,
25
+ body: str,
26
+ **kwargs: object,
27
+ ) -> bool:
28
+ """Send SMS via Twilio API."""
29
+ from twilio.rest import Client # type: ignore[import-untyped]
30
+
31
+ client = Client(
32
+ self._get_account_sid(),
33
+ self._get_auth_token(),
34
+ )
35
+ client.messages.create(
36
+ body=body,
37
+ from_=self._get_from_number(),
38
+ to=recipient,
39
+ )
40
+ return True
41
+
42
+ def validate_recipient(self, recipient: str) -> bool:
43
+ """Validate E.164 phone number format."""
44
+ return bool(E164_PATTERN.match(recipient))
45
+
46
+ def health_check(self) -> dict[str, bool | str]:
47
+ """Check Twilio credentials are configured."""
48
+ try:
49
+ self._get_account_sid()
50
+ return {"healthy": True, "channel": self.name}
51
+ except Exception as exc:
52
+ return {
53
+ "healthy": False,
54
+ "channel": self.name,
55
+ "error": str(exc),
56
+ }
57
+
58
+ def _get_account_sid(self) -> str:
59
+ from django.conf import settings
60
+ return settings.TWILIO_ACCOUNT_SID # type: ignore[attr-defined]
61
+
62
+ def _get_auth_token(self) -> str:
63
+ from django.conf import settings
64
+ return settings.TWILIO_AUTH_TOKEN # type: ignore[attr-defined]
65
+
66
+ def _get_from_number(self) -> str:
67
+ from django.conf import settings
68
+ return settings.TWILIO_FROM_NUMBER # type: ignore[attr-defined]
@@ -0,0 +1,74 @@
1
+ """Telegram Bot notification channel (ADR-088).
2
+
3
+ Requires: TELEGRAM_BOT_TOKEN in Django settings (ADR-045).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ import httpx
11
+
12
+ from platform_notifications.channels.base import BaseChannel
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ TELEGRAM_API_BASE = "https://api.telegram.org/bot"
17
+
18
+
19
+ class TelegramChannel(BaseChannel):
20
+ """Telegram notifications via Bot API.
21
+
22
+ recipient = chat_id (string or numeric).
23
+ """
24
+
25
+ name = "telegram"
26
+
27
+ def deliver(
28
+ self,
29
+ recipient: str,
30
+ subject: str,
31
+ body: str,
32
+ **kwargs: object,
33
+ ) -> bool:
34
+ """Send message via Telegram Bot API."""
35
+ token = self._get_bot_token()
36
+ text = f"*{subject}*\n{body}" if subject else body
37
+ url = f"{TELEGRAM_API_BASE}{token}/sendMessage"
38
+
39
+ with httpx.Client(timeout=self.config.timeout) as client:
40
+ response = client.post(
41
+ url,
42
+ json={
43
+ "chat_id": recipient,
44
+ "text": text[:4096],
45
+ "parse_mode": "Markdown",
46
+ },
47
+ )
48
+ response.raise_for_status()
49
+ return True
50
+
51
+ def validate_recipient(self, recipient: str) -> bool:
52
+ """Validate Telegram chat_id (numeric string, optionally negative)."""
53
+ cleaned = recipient.lstrip("-")
54
+ return cleaned.isdigit() and len(cleaned) <= 20
55
+
56
+ def health_check(self) -> dict[str, bool | str]:
57
+ """Check Telegram bot token is configured."""
58
+ try:
59
+ self._get_bot_token()
60
+ return {"healthy": True, "channel": self.name}
61
+ except Exception as exc:
62
+ return {
63
+ "healthy": False,
64
+ "channel": self.name,
65
+ "error": str(exc),
66
+ }
67
+
68
+ def _get_bot_token(self) -> str:
69
+ from django.conf import settings
70
+
71
+ token = getattr(settings, "TELEGRAM_BOT_TOKEN", "")
72
+ if not token:
73
+ raise ValueError("TELEGRAM_BOT_TOKEN not configured")
74
+ return token
@@ -0,0 +1,41 @@
1
+ """Generic webhook notification channel (ADR-088)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from platform_notifications.channels.base import BaseChannel
8
+
9
+
10
+ class WebhookChannel(BaseChannel):
11
+ """Generic HTTPS webhook channel."""
12
+
13
+ name = "webhook"
14
+
15
+ def deliver(
16
+ self,
17
+ recipient: str,
18
+ subject: str,
19
+ body: str,
20
+ **kwargs: object,
21
+ ) -> bool:
22
+ """POST notification payload to webhook URL."""
23
+ with httpx.Client(timeout=self.config.timeout) as client:
24
+ response = client.post(
25
+ recipient,
26
+ json={
27
+ "subject": subject,
28
+ "body": body,
29
+ **kwargs,
30
+ },
31
+ )
32
+ response.raise_for_status()
33
+ return True
34
+
35
+ def validate_recipient(self, recipient: str) -> bool:
36
+ """Validate HTTPS URL."""
37
+ return recipient.startswith("https://")
38
+
39
+ def health_check(self) -> dict[str, bool | str]:
40
+ """Webhook health is always true (no persistent connection)."""
41
+ return {"healthy": True, "channel": self.name}