goatnotify-django 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis Midson LAJEANTY <midsonlajeanty@proton.me>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: goatnotify-django
3
+ Version: 0.1.0
4
+ Summary: Django integration for GoatNotify: email backend, templates, ORM store and Django Tasks.
5
+ Keywords: notifications,email,smtp,webhooks,asyncio,typed
6
+ Author: Louis Midson LAJEANTY
7
+ Author-email: Louis Midson LAJEANTY <midsonlajeanty@proton.me>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Dist: goatnotify-core==0.1.0
17
+ Requires-Dist: asgiref>=3.9.1
18
+ Requires-Dist: django>=6.1
19
+ Requires-Python: >=3.14
20
+ Project-URL: Homepage, https://github.com/goatlabshq/goatnotify
21
+ Project-URL: Documentation, https://goatlabshq.github.io/goatnotify/
22
+ Project-URL: Issues, https://github.com/goatlabshq/goatnotify/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # goatnotify-django
26
+
27
+ Django integration for GoatNotify: email backend, templates, ORM store and Django Tasks.
28
+
29
+ ```bash
30
+ pip install goatnotify-django
31
+ ```
32
+
33
+ Documentation: <https://goatlabshq.github.io/goatnotify/>
@@ -0,0 +1,9 @@
1
+ # goatnotify-django
2
+
3
+ Django integration for GoatNotify: email backend, templates, ORM store and Django Tasks.
4
+
5
+ ```bash
6
+ pip install goatnotify-django
7
+ ```
8
+
9
+ Documentation: <https://goatlabshq.github.io/goatnotify/>
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "goatnotify-django"
3
+ version = "0.1.0"
4
+ description = "Django integration for GoatNotify: email backend, templates, ORM store and Django Tasks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ keywords = [
10
+ "notifications",
11
+ "email",
12
+ "smtp",
13
+ "webhooks",
14
+ "asyncio",
15
+ "typed",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Framework :: Django",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "goatnotify-core==0.1.0",
27
+ "asgiref>=3.9.1",
28
+ "django>=6.1",
29
+ ]
30
+
31
+ [[project.authors]]
32
+ name = "Louis Midson LAJEANTY"
33
+ email = "midsonlajeanty@proton.me"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/goatlabshq/goatnotify"
37
+ Documentation = "https://goatlabshq.github.io/goatnotify/"
38
+ Issues = "https://github.com/goatlabshq/goatnotify/issues"
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.12,<0.14"]
42
+ build-backend = "uv_build"
43
+
44
+ [tool.uv.build-backend]
45
+ module-name = "goatnotify.django"
46
+
47
+ [tool.uv.sources.goatnotify-core]
48
+ workspace = true
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "goatnotify-django"
3
+ version = "0.1.0" # x-release-please-version
4
+ description = "Django integration for GoatNotify: email backend, templates, ORM store and Django Tasks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Louis Midson LAJEANTY", email = "midsonlajeanty@proton.me" }]
9
+ requires-python = ">=3.14"
10
+ keywords = ["notifications", "email", "smtp", "webhooks", "asyncio", "typed"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: Django",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "goatnotify-core==0.1.0", # x-release-please-version
21
+ "asgiref>=3.9.1",
22
+ "django>=6.1",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/goatlabshq/goatnotify"
27
+ Documentation = "https://goatlabshq.github.io/goatnotify/"
28
+ Issues = "https://github.com/goatlabshq/goatnotify/issues"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.12,<0.14"]
32
+ build-backend = "uv_build"
33
+
34
+ [tool.uv.build-backend]
35
+ module-name = "goatnotify.django"
36
+
37
+ [tool.uv.sources]
38
+ goatnotify-core = { workspace = true }
@@ -0,0 +1,18 @@
1
+ from django.apps import AppConfig
2
+ from django.core import checks
3
+ from django.core.signals import setting_changed
4
+
5
+
6
+ class GoatNotifyConfig(AppConfig):
7
+ name = "goatnotify.django"
8
+ label = "goatnotify"
9
+ verbose_name = "GoatNotify"
10
+ default_auto_field = "django.db.models.BigAutoField"
11
+
12
+ def ready(self) -> None:
13
+ # Imported late: the notifier module loads models, which requires a ready app registry.
14
+ from goatnotify.django.checks import check_settings # noqa: PLC0415
15
+ from goatnotify.django.notifier import reset_notifier_on_setting_change # noqa: PLC0415
16
+
17
+ setting_changed.connect(reset_notifier_on_setting_change)
18
+ checks.register(check_settings)
@@ -0,0 +1,61 @@
1
+ from django.conf import settings
2
+ from django.core.checks import CheckMessage, Error
3
+ from django.core.mail import mailers
4
+ from django.core.mail.handler import DEFAULT_MAILER_ALIAS
5
+ from django.utils.module_loading import import_string
6
+ from goatnotify.core.errors import InvalidMessageError
7
+ from goatnotify.core.mail.message import Address
8
+
9
+ HINT_FACTORY = "Set GOATNOTIFY_NOTIFIER to the dotted path of a function returning a Notifier."
10
+
11
+
12
+ def check_settings(**_: object) -> list[CheckMessage]:
13
+ path: object = getattr(settings, "GOATNOTIFY_NOTIFIER", None)
14
+ if path is None:
15
+ return _default_notifier_errors()
16
+ if not isinstance(path, str):
17
+ return [
18
+ Error("GOATNOTIFY_NOTIFIER must be a string.", hint=HINT_FACTORY, id="goatnotify.E001")
19
+ ]
20
+ try:
21
+ factory: object = import_string(path)
22
+ except ImportError as error:
23
+ return [
24
+ Error(
25
+ f"GOATNOTIFY_NOTIFIER cannot be imported: {error}",
26
+ hint=HINT_FACTORY,
27
+ id="goatnotify.E002",
28
+ )
29
+ ]
30
+ if not callable(factory):
31
+ return [
32
+ Error(
33
+ f"GOATNOTIFY_NOTIFIER {path!r} is not callable.",
34
+ hint=HINT_FACTORY,
35
+ id="goatnotify.E003",
36
+ )
37
+ ]
38
+ return []
39
+
40
+
41
+ def _default_notifier_errors() -> list[CheckMessage]:
42
+ errors: list[CheckMessage] = []
43
+ try:
44
+ Address.parse(settings.DEFAULT_FROM_EMAIL)
45
+ except InvalidMessageError:
46
+ errors.append(
47
+ Error(
48
+ f"DEFAULT_FROM_EMAIL {settings.DEFAULT_FROM_EMAIL!r} is not an email address.",
49
+ hint='Use "noreply@example.com" or "Name <noreply@example.com>".',
50
+ id="goatnotify.E004",
51
+ )
52
+ )
53
+ if hasattr(settings, "MAILERS") and DEFAULT_MAILER_ALIAS not in mailers:
54
+ errors.append(
55
+ Error(
56
+ "MAILERS has no 'default' mailer, which the default notifier sends email through.",
57
+ hint="Add a 'default' entry to MAILERS or set GOATNOTIFY_NOTIFIER.",
58
+ id="goatnotify.E005",
59
+ )
60
+ )
61
+ return errors
@@ -0,0 +1,86 @@
1
+ import email.policy
2
+ import smtplib
3
+ from email.message import EmailMessage as MimeMessage
4
+ from email.policy import Policy
5
+ from email.utils import make_msgid
6
+ from typing import override
7
+
8
+ from asgiref.sync import sync_to_async
9
+
10
+ from django.core.mail import EmailMultiAlternatives
11
+ from django.core.mail.handler import DEFAULT_MAILER_ALIAS
12
+ from goatnotify.core.errors import PermanentError
13
+ from goatnotify.core.mail.message import Address, Attachment, EmailMessage
14
+ from goatnotify.core.mail.smtp import classify_smtp_error
15
+
16
+
17
+ def _formatted(addresses: tuple[Address, ...]) -> list[str]:
18
+ return [str(address) for address in addresses]
19
+
20
+
21
+ def _pop_message_id(headers: dict[str, str]) -> str | None:
22
+ name = next((name for name in headers if name.lower() == "message-id"), None)
23
+ return None if name is None else headers.pop(name)
24
+
25
+
26
+ class _EmailWithInlineImages(EmailMultiAlternatives):
27
+ # Django has no inline-image API; message() is the public hook to wrap the HTML body.
28
+ inline: tuple[Attachment, ...] = ()
29
+
30
+ @override
31
+ def message(self, *, policy: Policy | None = email.policy.default) -> MimeMessage:
32
+ mime = super().message(policy=policy)
33
+ html = mime.get_body(("html",))
34
+ if html is None:
35
+ return mime
36
+ for attachment in self.inline:
37
+ maintype, subtype = attachment.mimetype.split("/")
38
+ html.add_related(
39
+ attachment.content,
40
+ maintype=maintype,
41
+ subtype=subtype,
42
+ cid=f"<{attachment.filename}>",
43
+ filename=attachment.filename,
44
+ disposition="inline",
45
+ )
46
+ return mime
47
+
48
+
49
+ class DjangoEmailTransport:
50
+ def __init__(self, *, mailer: str = DEFAULT_MAILER_ALIAS) -> None:
51
+ self._mailer = mailer
52
+
53
+ async def send(self, message: EmailMessage) -> str | None:
54
+ # Off the shared sync thread: a slow SMTP server would stall every sync view and ORM call.
55
+ return await sync_to_async(self._send, thread_sensitive=False)(message)
56
+
57
+ def _send(self, message: EmailMessage) -> str:
58
+ headers = dict(message.headers)
59
+ message_id = _pop_message_id(headers) or make_msgid(
60
+ domain=message.sender.email.rpartition("@")[2]
61
+ )
62
+ outgoing = _EmailWithInlineImages(
63
+ subject=message.subject,
64
+ body=message.text if message.text is not None else message.html or "",
65
+ from_email=str(message.sender),
66
+ to=_formatted(message.to),
67
+ cc=_formatted(message.cc),
68
+ bcc=_formatted(message.bcc),
69
+ reply_to=_formatted(message.reply_to),
70
+ headers={**headers, "Message-ID": message_id},
71
+ )
72
+ if message.text is None:
73
+ outgoing.content_subtype = "html"
74
+ elif message.html is not None:
75
+ outgoing.attach_alternative(message.html, "text/html")
76
+ outgoing.inline = tuple(a for a in message.attachments if a.inline)
77
+ for attachment in message.attachments:
78
+ if not attachment.inline:
79
+ outgoing.attach(attachment.filename, attachment.content, attachment.mimetype)
80
+ try:
81
+ sent = outgoing.send(using=self._mailer)
82
+ except (smtplib.SMTPException, OSError) as error:
83
+ raise classify_smtp_error(error) from error
84
+ if sent == 0:
85
+ raise PermanentError("The Django email backend accepted no message")
86
+ return message_id
@@ -0,0 +1,50 @@
1
+ # Generated by Django 6.1.1 on 2026-09-25 22:39
2
+
3
+ import uuid
4
+
5
+ import django.db.models.deletion
6
+ import django.utils.timezone
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ("contenttypes", "0002_remove_content_type_name"),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="NotificationRecord",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.UUIDField(
24
+ default=uuid.uuid4, editable=False, primary_key=True, serialize=False
25
+ ),
26
+ ),
27
+ ("object_id", models.CharField(max_length=255)),
28
+ ("kind", models.CharField(max_length=255)),
29
+ ("data", models.JSONField(default=dict)),
30
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
31
+ ("read_at", models.DateTimeField(blank=True, null=True)),
32
+ (
33
+ "content_type",
34
+ models.ForeignKey(
35
+ on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
36
+ ),
37
+ ),
38
+ ],
39
+ options={
40
+ "db_table": "goatnotify_notification",
41
+ "ordering": ("-created_at",),
42
+ "indexes": [
43
+ models.Index(
44
+ fields=["content_type", "object_id", "-created_at"],
45
+ name="goatnotify_recipient_idx",
46
+ )
47
+ ],
48
+ },
49
+ ),
50
+ ]
@@ -0,0 +1,35 @@
1
+ # Generated by Django 6.1.2 on 2026-10-19 02:38
2
+
3
+ import django.utils.timezone
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("goatnotify", "0001_initial"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="DeliveryReceipt",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.BigAutoField(
19
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
20
+ ),
21
+ ),
22
+ ("job_id", models.UUIDField()),
23
+ ("position", models.PositiveIntegerField()),
24
+ ("delivered_at", models.DateTimeField(default=django.utils.timezone.now)),
25
+ ],
26
+ options={
27
+ "db_table": "goatnotify_delivery_receipt",
28
+ "constraints": [
29
+ models.UniqueConstraint(
30
+ fields=("job_id", "position"), name="goatnotify_receipt_unique"
31
+ )
32
+ ],
33
+ },
34
+ ),
35
+ ]
@@ -0,0 +1,84 @@
1
+ import uuid
2
+ from datetime import datetime
3
+ from typing import Self
4
+
5
+ from django.contrib.contenttypes.fields import GenericForeignKey
6
+ from django.contrib.contenttypes.models import ContentType
7
+ from django.db import models
8
+ from django.utils import timezone
9
+
10
+
11
+ class NotificationRecordQuerySet(models.QuerySet["NotificationRecord"]):
12
+ def for_recipient(self, recipient: models.Model) -> Self:
13
+ return self.filter(
14
+ content_type=ContentType.objects.get_for_model(recipient),
15
+ object_id=str(recipient.pk),
16
+ )
17
+
18
+ def unread(self) -> Self:
19
+ return self.filter(read_at__isnull=True)
20
+
21
+ def mark_as_read(self) -> int:
22
+ return self.unread().update(read_at=timezone.now())
23
+
24
+
25
+ class NotificationRecordManager(models.Manager["NotificationRecord"]):
26
+ def get_queryset(self) -> NotificationRecordQuerySet:
27
+ return NotificationRecordQuerySet(self.model, using=self._db)
28
+
29
+ def for_recipient(self, recipient: models.Model) -> NotificationRecordQuerySet:
30
+ return self.get_queryset().for_recipient(recipient)
31
+
32
+
33
+ class NotificationRecord(models.Model):
34
+ id: models.UUIDField[uuid.UUID, uuid.UUID] = models.UUIDField(
35
+ primary_key=True, default=uuid.uuid4, editable=False
36
+ )
37
+ content_type: models.ForeignKey[ContentType, ContentType] = models.ForeignKey(
38
+ ContentType, on_delete=models.CASCADE
39
+ )
40
+ content_type_id: int
41
+ object_id: models.CharField[str, str] = models.CharField(max_length=255)
42
+ recipient = GenericForeignKey("content_type", "object_id")
43
+ kind: models.CharField[str, str] = models.CharField(max_length=255)
44
+ data = models.JSONField(default=dict)
45
+ created_at: models.DateTimeField[datetime, datetime] = models.DateTimeField(
46
+ default=timezone.now
47
+ )
48
+ read_at: models.DateTimeField[datetime | None, datetime | None] = models.DateTimeField(
49
+ null=True, blank=True
50
+ )
51
+
52
+ objects = NotificationRecordManager()
53
+
54
+ class Meta:
55
+ db_table = "goatnotify_notification"
56
+ ordering = ("-created_at",)
57
+ indexes = (
58
+ models.Index(
59
+ fields=("content_type", "object_id", "-created_at"),
60
+ name="goatnotify_recipient_idx",
61
+ ),
62
+ )
63
+
64
+ def __str__(self) -> str:
65
+ return f"{self.kind} for {self.content_type_id}:{self.object_id}"
66
+
67
+
68
+ class DeliveryReceipt(models.Model):
69
+ job_id: models.UUIDField[uuid.UUID, uuid.UUID] = models.UUIDField()
70
+ position: models.PositiveIntegerField[int, int] = models.PositiveIntegerField()
71
+ delivered_at: models.DateTimeField[datetime, datetime] = models.DateTimeField(
72
+ default=timezone.now
73
+ )
74
+
75
+ class Meta:
76
+ db_table = "goatnotify_delivery_receipt"
77
+ constraints = (
78
+ models.UniqueConstraint(
79
+ fields=("job_id", "position"), name="goatnotify_receipt_unique"
80
+ ),
81
+ )
82
+
83
+ def __str__(self) -> str:
84
+ return f"{self.job_id}#{self.position}"
@@ -0,0 +1,6 @@
1
+ class NotifiableModel:
2
+ def route_notification(self, channel: str) -> str | None:
3
+ if channel != "email":
4
+ return None
5
+ email = getattr(self, "email", None)
6
+ return email if isinstance(email, str) and email else None
@@ -0,0 +1,67 @@
1
+ from functools import cache
2
+ from typing import Final
3
+
4
+ from asgiref.sync import async_to_sync
5
+
6
+ from django.conf import settings
7
+ from django.utils.module_loading import import_string
8
+ from goatnotify.core.channels.database import DatabaseChannel
9
+ from goatnotify.core.channels.email import EmailChannel
10
+ from goatnotify.core.channels.webhook import WebhookChannel
11
+ from goatnotify.core.errors import ConfigurationError
12
+ from goatnotify.core.mail.mailer import Mailer
13
+ from goatnotify.core.notification import Notifiable, Notification
14
+ from goatnotify.core.notifier import Notifier
15
+ from goatnotify.core.results import NotificationResult
16
+ from goatnotify.django.mail import DjangoEmailTransport
17
+ from goatnotify.django.queue import DjangoTasksQueue
18
+ from goatnotify.django.store import DjangoNotificationStore
19
+ from goatnotify.django.templates import DjangoTemplateRenderer
20
+
21
+ WATCHED_SETTINGS: Final = frozenset(
22
+ {"GOATNOTIFY_NOTIFIER", "DEFAULT_FROM_EMAIL", "MAILERS", "TASKS", "TEMPLATES"}
23
+ )
24
+
25
+
26
+ def default_notifier() -> Notifier:
27
+ mailer = Mailer(
28
+ DjangoEmailTransport(),
29
+ sender=settings.DEFAULT_FROM_EMAIL,
30
+ renderer=DjangoTemplateRenderer(),
31
+ )
32
+ return Notifier(
33
+ [EmailChannel(mailer), DatabaseChannel(DjangoNotificationStore()), WebhookChannel()],
34
+ queue=DjangoTasksQueue(),
35
+ )
36
+
37
+
38
+ @cache
39
+ def get_notifier() -> Notifier:
40
+ path: object = getattr(settings, "GOATNOTIFY_NOTIFIER", None)
41
+ if path is None:
42
+ return default_notifier()
43
+ if not isinstance(path, str):
44
+ raise ConfigurationError("GOATNOTIFY_NOTIFIER must be a dotted path to a factory")
45
+ notifier: object = import_string(path)()
46
+ if not isinstance(notifier, Notifier):
47
+ raise ConfigurationError(f"{path} must return a Notifier")
48
+ return notifier
49
+
50
+
51
+ def reset_notifier_on_setting_change(*, setting: str, **_: object) -> None:
52
+ if setting in WATCHED_SETTINGS:
53
+ get_notifier.cache_clear()
54
+
55
+
56
+ # Notifier.notify_sync() uses asyncio.run(), which deadlocks inside Django's thread-sensitive
57
+ # sync views (ASGI); async_to_sync lets the adapters' sync_to_async calls run inline instead.
58
+ def notify_sync[R: Notifiable](
59
+ notifiable: R, notification: Notification[R], *, raise_on_failure: bool = True
60
+ ) -> NotificationResult:
61
+ return async_to_sync(get_notifier().notify)(
62
+ notifiable, notification, raise_on_failure=raise_on_failure
63
+ )
64
+
65
+
66
+ def enqueue_sync[R: Notifiable](notifiable: R, notification: Notification[R]) -> None:
67
+ async_to_sync(get_notifier().enqueue)(notifiable, notification)
File without changes
@@ -0,0 +1,31 @@
1
+ from uuid import uuid4
2
+
3
+ from django.db import models
4
+ from goatnotify.core.errors import ConfigurationError
5
+ from goatnotify.core.notification import Notifiable
6
+ from goatnotify.core.queue import NotificationJob
7
+ from goatnotify.django.serialization import dump_model, dump_notification
8
+ from goatnotify.django.tasks import deliver_notification
9
+
10
+
11
+ class DjangoTasksQueue:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ queue_name: str | None = None,
16
+ priority: int | None = None,
17
+ backend: str | None = None,
18
+ ) -> None:
19
+ self._queue_name = queue_name
20
+ self._priority = priority
21
+ self._backend = backend
22
+
23
+ async def enqueue[R: Notifiable](self, job: NotificationJob[R]) -> None:
24
+ if not isinstance(job.notifiable, models.Model):
25
+ raise ConfigurationError("DjangoTasksQueue needs a Django model instance as notifiable")
26
+ notifiable = dump_model(job.notifiable)
27
+ notification = dump_notification(job.notification)
28
+ task = deliver_notification.using(
29
+ queue_name=self._queue_name, priority=self._priority, backend=self._backend
30
+ )
31
+ await task.aenqueue(notifiable, notification, job_id=str(uuid4()))
@@ -0,0 +1,109 @@
1
+ import dataclasses
2
+ import importlib
3
+ import math
4
+ from collections.abc import Mapping
5
+ from typing import Any, Final, cast
6
+
7
+ from django.apps import apps
8
+ from django.db import models
9
+ from goatnotify.core._json import JsonValue
10
+ from goatnotify.core.errors import ConfigurationError
11
+ from goatnotify.core.notification import Notifiable, Notification
12
+
13
+ MODEL_TAG: Final = "model"
14
+ JSON_TAG: Final = "json"
15
+ JSON_SCALARS: Final = (str, int, float, bool, type(None))
16
+
17
+
18
+ def dump_model(instance: models.Model) -> dict[str, JsonValue]:
19
+ if instance.pk is None:
20
+ raise ConfigurationError(f"{type(instance).__qualname__} must be saved before it is queued")
21
+ pk: object = instance.pk
22
+ json_pk: JsonValue = pk if isinstance(pk, str | int) and type(pk) is not bool else str(pk)
23
+ return {"label": instance._meta.label_lower, "pk": json_pk}
24
+
25
+
26
+ def load_model(reference: Mapping[str, JsonValue]) -> models.Model:
27
+ label = reference.get("label")
28
+ if not isinstance(label, str):
29
+ raise ConfigurationError("Malformed queued model reference")
30
+ return apps.get_model(label)._default_manager.get(pk=reference.get("pk"))
31
+
32
+
33
+ def load_notifiable(reference: Mapping[str, JsonValue]) -> Notifiable:
34
+ instance = load_model(reference)
35
+ if not callable(getattr(instance, "route_notification", None)):
36
+ raise ConfigurationError(f"{type(instance).__qualname__} is not notifiable")
37
+ return cast("Notifiable", instance)
38
+
39
+
40
+ def dump_notification(notification: Notification[Any]) -> dict[str, JsonValue]:
41
+ cls = type(notification)
42
+ if not dataclasses.is_dataclass(notification) or "<locals>" in cls.__qualname__:
43
+ raise ConfigurationError(
44
+ f"{cls.__qualname__} must be a module-level dataclass to be queued with Django Tasks"
45
+ )
46
+ fields: dict[str, JsonValue] = {}
47
+ for field in dataclasses.fields(notification):
48
+ if not field.init:
49
+ continue
50
+ value: object = getattr(notification, field.name)
51
+ if isinstance(value, models.Model):
52
+ fields[field.name] = {MODEL_TAG: dump_model(value)}
53
+ elif _round_trips(value):
54
+ fields[field.name] = {JSON_TAG: cast("JsonValue", value)}
55
+ else:
56
+ raise ConfigurationError(
57
+ f"{cls.__qualname__}.{field.name} must be plain JSON (str, int, float, bool, "
58
+ "None, list, dict with str keys) or a saved model instance"
59
+ )
60
+ return {"class": f"{cls.__module__}:{cls.__qualname__}", "fields": fields}
61
+
62
+
63
+ def load_notification(payload: Mapping[str, JsonValue]) -> Notification[Any]:
64
+ path, fields = payload.get("class"), payload.get("fields")
65
+ if not isinstance(path, str) or not isinstance(fields, dict):
66
+ raise ConfigurationError("Malformed queued notification")
67
+ module_name, _, qualname = path.partition(":")
68
+ # Task payloads may sit in a database table, so only installed apps' modules are importable.
69
+ if not _in_installed_app(module_name):
70
+ raise ConfigurationError(f"{module_name!r} is not part of an installed app")
71
+ target: object = importlib.import_module(module_name) # nosemgrep
72
+ for part in qualname.split("."):
73
+ target = getattr(target, part)
74
+ if not (isinstance(target, type) and issubclass(target, Notification)):
75
+ raise ConfigurationError(f"{path} is not a Notification")
76
+ arguments = {name: _load_field(value) for name, value in fields.items()}
77
+ return cast("type[Notification[Any]]", target)(**arguments)
78
+
79
+
80
+ def _round_trips(value: object) -> bool:
81
+ # Exact types: tuples, enums and other subclasses would come back as plain JSON values.
82
+ if type(value) is float:
83
+ return math.isfinite(value)
84
+ if type(value) in JSON_SCALARS:
85
+ return True
86
+ if type(value) is list:
87
+ return all(_round_trips(item) for item in cast("list[object]", value))
88
+ if type(value) is dict:
89
+ items = cast("dict[object, object]", value).items()
90
+ return all(type(key) is str and _round_trips(item) for key, item in items)
91
+ return False
92
+
93
+
94
+ def _load_field(encoded: JsonValue) -> object:
95
+ if not isinstance(encoded, dict) or len(encoded) != 1:
96
+ raise ConfigurationError("Malformed queued notification field")
97
+ ((tag, value),) = encoded.items()
98
+ if tag == JSON_TAG:
99
+ return value
100
+ if tag == MODEL_TAG and isinstance(value, dict):
101
+ return load_model(value)
102
+ raise ConfigurationError("Malformed queued notification field")
103
+
104
+
105
+ def _in_installed_app(module_name: str) -> bool:
106
+ return any(
107
+ module_name == config.name or module_name.startswith(f"{config.name}.")
108
+ for config in apps.get_app_configs()
109
+ )
@@ -0,0 +1,28 @@
1
+ from asgiref.sync import sync_to_async
2
+
3
+ from django.contrib.contenttypes.models import ContentType
4
+ from django.db import models
5
+ from goatnotify.core.channels.database import StoredNotification
6
+ from goatnotify.core.errors import ConfigurationError
7
+ from goatnotify.core.notification import Notifiable
8
+ from goatnotify.django.models import NotificationRecord
9
+
10
+
11
+ class DjangoNotificationStore:
12
+ async def store(self, notifiable: Notifiable, record: StoredNotification) -> None:
13
+ if not isinstance(notifiable, models.Model) or notifiable.pk is None:
14
+ kind = type(notifiable).__qualname__
15
+ raise ConfigurationError(
16
+ f"DjangoNotificationStore needs a saved model instance, not {kind}"
17
+ )
18
+ await sync_to_async(self._store)(notifiable, record)
19
+
20
+ def _store(self, recipient: models.Model, record: StoredNotification) -> None:
21
+ NotificationRecord.objects.create(
22
+ id=record.id,
23
+ content_type=ContentType.objects.get_for_model(recipient),
24
+ object_id=str(recipient.pk),
25
+ kind=record.kind,
26
+ data=dict(record.data),
27
+ created_at=record.created_at,
28
+ )
@@ -0,0 +1,57 @@
1
+ import logging
2
+ from typing import Final
3
+ from uuid import UUID
4
+
5
+ from asgiref.sync import sync_to_async
6
+
7
+ from django.core.exceptions import ObjectDoesNotExist
8
+ from django.tasks import task
9
+ from goatnotify.core._json import JsonValue
10
+ from goatnotify.core.results import DeliveryStatus, NotificationResult
11
+ from goatnotify.django.models import DeliveryReceipt
12
+ from goatnotify.django.serialization import load_notifiable, load_notification
13
+
14
+ logger: Final = logging.getLogger("goatnotify")
15
+ DONE: Final = frozenset({DeliveryStatus.SENT, DeliveryStatus.SKIPPED})
16
+
17
+
18
+ @task
19
+ async def deliver_notification(
20
+ notifiable: dict[str, JsonValue],
21
+ notification: dict[str, JsonValue],
22
+ job_id: str | None = None,
23
+ ) -> None:
24
+ # Imported here because the default notifier builds a DjangoTasksQueue that enqueues this task.
25
+ from goatnotify.django.notifier import get_notifier # noqa: PLC0415
26
+
27
+ try:
28
+ recipient = await sync_to_async(load_notifiable)(notifiable)
29
+ except ObjectDoesNotExist:
30
+ logger.warning(
31
+ "Skipped a queued notification: its recipient %s no longer exists", notifiable
32
+ )
33
+ return
34
+ instance = await sync_to_async(load_notification)(notification)
35
+ job = None if job_id is None else UUID(job_id)
36
+ delivered: set[int] = set() if job is None else await sync_to_async(_delivered_positions)(job)
37
+ result = await get_notifier().notify(
38
+ recipient, instance, raise_on_failure=False, skip=delivered
39
+ )
40
+ if job is not None:
41
+ await sync_to_async(_record)(job, delivered, result)
42
+ result.raise_for_failures()
43
+
44
+
45
+ def _delivered_positions(job: UUID) -> set[int]:
46
+ return set(DeliveryReceipt.objects.filter(job_id=job).values_list("position", flat=True))
47
+
48
+
49
+ def _record(job: UUID, already: set[int], result: NotificationResult) -> None:
50
+ DeliveryReceipt.objects.bulk_create(
51
+ [
52
+ DeliveryReceipt(job_id=job, position=position)
53
+ for position, delivery in enumerate(result.deliveries)
54
+ if delivery.status in DONE and position not in already
55
+ ],
56
+ ignore_conflicts=True,
57
+ )
@@ -0,0 +1,13 @@
1
+ from collections.abc import Mapping
2
+
3
+ from asgiref.sync import sync_to_async
4
+
5
+ from django.template.loader import render_to_string
6
+
7
+
8
+ class DjangoTemplateRenderer:
9
+ def __init__(self, *, using: str | None = None) -> None:
10
+ self._using = using
11
+
12
+ async def render(self, template: str, context: Mapping[str, object]) -> str:
13
+ return await sync_to_async(render_to_string)(template, dict(context), using=self._using)