fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# notifications app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationTemplate',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('event', models.CharField(db_index=True, max_length=120)),
|
||||
('channel', models.CharField(choices=[('sms', 'SMS'), ('email', 'Email'), ('push', 'Push'), ('inapp', 'In-app')], default='sms', max_length=12)),
|
||||
('subject', models.CharField(blank=True, max_length=255)),
|
||||
('body', models.TextField(help_text='Supports {{variable}} placeholders.')),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationRule',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('event', models.CharField(db_index=True, max_length=120)),
|
||||
('recipients', models.JSONField(blank=True, default=list)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='notifications.notificationtemplate')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationLog',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('recipient', models.CharField(blank=True, max_length=200)),
|
||||
('channel', models.CharField(max_length=12)),
|
||||
('status', models.CharField(default='pending', max_length=20)),
|
||||
('error', models.TextField(blank=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='notifications.notificationtemplate')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class NotificationTemplate(UUIDModel, TimeStampedModel):
|
||||
class Channel(models.TextChoices):
|
||||
SMS = "sms", "SMS"
|
||||
EMAIL = "email", "Email"
|
||||
PUSH = "push", "Push"
|
||||
INAPP = "inapp", "In-app"
|
||||
|
||||
event = models.CharField(max_length=120, db_index=True)
|
||||
channel = models.CharField(max_length=12, choices=Channel.choices, default=Channel.SMS)
|
||||
subject = models.CharField(max_length=255, blank=True)
|
||||
body = models.TextField(help_text="Supports {{variable}} placeholders.")
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.event} ({self.channel})"
|
||||
|
||||
|
||||
class NotificationRule(UUIDModel, TimeStampedModel):
|
||||
event = models.CharField(max_length=120, db_index=True)
|
||||
template = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE, related_name="rules")
|
||||
recipients = models.JSONField(default=list, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
|
||||
class NotificationLog(UUIDModel, TimeStampedModel):
|
||||
template = models.ForeignKey(NotificationTemplate, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
recipient = models.CharField(max_length=200, blank=True)
|
||||
channel = models.CharField(max_length=12)
|
||||
status = models.CharField(max_length=20, default="pending")
|
||||
error = models.TextField(blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pluggable SMS / notification providers.
|
||||
|
||||
Providers are selected at runtime from the dynamic settings store, so an SMS
|
||||
gateway can be configured from the admin panel without touching code. Business
|
||||
logic only ever talks to the provider interface.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Protocol
|
||||
|
||||
from apps.core.settings_service import SettingsService
|
||||
|
||||
|
||||
class SmsProviderInterface(Protocol):
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
"""Return the provider message id."""
|
||||
...
|
||||
|
||||
|
||||
class MockSmsProvider:
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
return f"MOCK-{recipient}-{abs(hash(message)) % 10_000_000}"
|
||||
|
||||
|
||||
class HttpSmsProvider:
|
||||
"""Generic HTTP gateway: POSTs a JSON payload to a configurable endpoint."""
|
||||
|
||||
def __init__(self, endpoint: str, api_key: str = "", sender: str = ""):
|
||||
self.endpoint = endpoint
|
||||
self.api_key = api_key
|
||||
self.sender = sender
|
||||
|
||||
def send(self, recipient: str, message: str) -> str:
|
||||
payload = {"to": recipient, "text": message, "from": self.sender, "api_key": self.api_key}
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return response.read().decode("utf-8", "replace")[:120]
|
||||
|
||||
|
||||
def get_sms_provider() -> SmsProviderInterface:
|
||||
service = SettingsService()
|
||||
provider = service.get("sms.provider", "mock")
|
||||
if provider == "http":
|
||||
return HttpSmsProvider(
|
||||
endpoint=service.get("sms.endpoint", ""),
|
||||
api_key=service.get("sms.api_key", ""),
|
||||
sender=service.get("sms.sender", ""),
|
||||
)
|
||||
return MockSmsProvider()
|
||||
|
||||
|
||||
def render_template(template_text: str, context: dict) -> str:
|
||||
def _replace(match):
|
||||
key = match.group(1)
|
||||
return str(context.get(key, ""))
|
||||
|
||||
return re.sub(r"\{\{\s*(\w+)\s*\}\}", _replace, template_text)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Notification engine.
|
||||
|
||||
Resolves templates for a domain event from the database, renders them and
|
||||
dispatches through the configured provider. Business logic only emits events.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.notifications.models import NotificationLog, NotificationTemplate
|
||||
from apps.notifications.providers import get_sms_provider, render_template
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def send_event(self, event: str, context: dict, recipient: str = "") -> list:
|
||||
results = []
|
||||
templates = NotificationTemplate.objects.filter(event=event, enabled=True)
|
||||
for template in templates:
|
||||
body = render_template(template.body, context)
|
||||
if template.channel == NotificationTemplate.Channel.SMS:
|
||||
results.append(self._send_sms(template, recipient, body))
|
||||
return results
|
||||
|
||||
def _send_sms(self, template, recipient: str, body: str) -> dict:
|
||||
status = "sent"
|
||||
error = ""
|
||||
reference = ""
|
||||
try:
|
||||
reference = get_sms_provider().send(recipient, body)
|
||||
except Exception as exc: # pragma: no cover - provider failures
|
||||
status = "failed"
|
||||
error = str(exc)
|
||||
NotificationLog.objects.create(
|
||||
template=template,
|
||||
recipient=recipient,
|
||||
channel="sms",
|
||||
status=status,
|
||||
error=error,
|
||||
sent_at=timezone.now() if status == "sent" else None,
|
||||
)
|
||||
return {"channel": "sms", "status": status, "reference": reference}
|
||||
Reference in New Issue
Block a user