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.
- goatnotify_django-0.1.0/LICENSE +21 -0
- goatnotify_django-0.1.0/PKG-INFO +33 -0
- goatnotify_django-0.1.0/README.md +9 -0
- goatnotify_django-0.1.0/pyproject.toml +48 -0
- goatnotify_django-0.1.0/pyproject.toml.orig +38 -0
- goatnotify_django-0.1.0/src/goatnotify/django/__init__.py +0 -0
- goatnotify_django-0.1.0/src/goatnotify/django/apps.py +18 -0
- goatnotify_django-0.1.0/src/goatnotify/django/checks.py +61 -0
- goatnotify_django-0.1.0/src/goatnotify/django/mail.py +86 -0
- goatnotify_django-0.1.0/src/goatnotify/django/migrations/0001_initial.py +50 -0
- goatnotify_django-0.1.0/src/goatnotify/django/migrations/0002_delivery_receipt.py +35 -0
- goatnotify_django-0.1.0/src/goatnotify/django/migrations/__init__.py +0 -0
- goatnotify_django-0.1.0/src/goatnotify/django/models.py +84 -0
- goatnotify_django-0.1.0/src/goatnotify/django/notifiable.py +6 -0
- goatnotify_django-0.1.0/src/goatnotify/django/notifier.py +67 -0
- goatnotify_django-0.1.0/src/goatnotify/django/py.typed +0 -0
- goatnotify_django-0.1.0/src/goatnotify/django/queue.py +31 -0
- goatnotify_django-0.1.0/src/goatnotify/django/serialization.py +109 -0
- goatnotify_django-0.1.0/src/goatnotify/django/store.py +28 -0
- goatnotify_django-0.1.0/src/goatnotify/django/tasks.py +57 -0
- goatnotify_django-0.1.0/src/goatnotify/django/templates.py +13 -0
|
@@ -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,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 }
|
|
File without changes
|
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -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,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)
|