arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
core/views.py
CHANGED
|
@@ -3,19 +3,24 @@ import shutil
|
|
|
3
3
|
from datetime import timedelta
|
|
4
4
|
|
|
5
5
|
import requests
|
|
6
|
+
from django.conf import settings
|
|
6
7
|
from django.contrib.admin.views.decorators import staff_member_required
|
|
7
8
|
from django.contrib.auth import authenticate, login
|
|
9
|
+
from django.contrib import messages
|
|
10
|
+
from django.contrib.sites.models import Site
|
|
8
11
|
from django.http import Http404, JsonResponse
|
|
9
|
-
from django.shortcuts import get_object_or_404, render,
|
|
10
|
-
from django.views.decorators.csrf import csrf_exempt
|
|
11
|
-
from django.views.decorators.http import require_POST
|
|
12
|
-
from django.utils.translation import gettext as _
|
|
12
|
+
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
|
13
13
|
from django.utils import timezone
|
|
14
|
+
from django.utils.translation import gettext as _
|
|
14
15
|
from django.urls import NoReverseMatch, reverse
|
|
16
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
17
|
+
from django.views.decorators.http import require_GET, require_POST
|
|
18
|
+
from django.utils.http import url_has_allowed_host_and_scheme
|
|
15
19
|
from pathlib import Path
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
16
21
|
import subprocess
|
|
17
|
-
import json
|
|
18
22
|
|
|
23
|
+
from utils import revision
|
|
19
24
|
from utils.api import api_login_required
|
|
20
25
|
|
|
21
26
|
from .models import Product, EnergyAccount, PackageRelease, Todo
|
|
@@ -42,6 +47,22 @@ def odoo_products(request):
|
|
|
42
47
|
return JsonResponse(items, safe=False)
|
|
43
48
|
|
|
44
49
|
|
|
50
|
+
@require_GET
|
|
51
|
+
def version_info(request):
|
|
52
|
+
"""Return the running application version and Git revision."""
|
|
53
|
+
|
|
54
|
+
version = ""
|
|
55
|
+
version_path = Path(settings.BASE_DIR) / "VERSION"
|
|
56
|
+
if version_path.exists():
|
|
57
|
+
version = version_path.read_text(encoding="utf-8").strip()
|
|
58
|
+
return JsonResponse(
|
|
59
|
+
{
|
|
60
|
+
"version": version,
|
|
61
|
+
"revision": revision.get_revision(),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
45
66
|
from . import release as release_utils
|
|
46
67
|
|
|
47
68
|
|
|
@@ -60,6 +81,53 @@ def _clean_repo() -> None:
|
|
|
60
81
|
subprocess.run(["git", "clean", "-fd"], check=False)
|
|
61
82
|
|
|
62
83
|
|
|
84
|
+
def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
|
|
85
|
+
"""Ensure ``release`` matches the repository revision and version.
|
|
86
|
+
|
|
87
|
+
Returns a tuple ``(updated, previous_version)`` where ``updated`` is
|
|
88
|
+
``True`` when any field changed and ``previous_version`` is the version
|
|
89
|
+
before synchronization.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from packaging.version import InvalidVersion, Version
|
|
93
|
+
|
|
94
|
+
previous_version = release.version
|
|
95
|
+
updated_fields: set[str] = set()
|
|
96
|
+
|
|
97
|
+
repo_version: Version | None = None
|
|
98
|
+
version_path = Path("VERSION")
|
|
99
|
+
if version_path.exists():
|
|
100
|
+
try:
|
|
101
|
+
repo_version = Version(version_path.read_text(encoding="utf-8").strip())
|
|
102
|
+
except InvalidVersion:
|
|
103
|
+
repo_version = None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
release_version = Version(release.version)
|
|
107
|
+
except InvalidVersion:
|
|
108
|
+
release_version = None
|
|
109
|
+
|
|
110
|
+
if repo_version is not None:
|
|
111
|
+
bumped_repo_version = Version(
|
|
112
|
+
f"{repo_version.major}.{repo_version.minor}.{repo_version.micro + 1}"
|
|
113
|
+
)
|
|
114
|
+
if release_version is None or release_version < bumped_repo_version:
|
|
115
|
+
release.version = str(bumped_repo_version)
|
|
116
|
+
release_version = bumped_repo_version
|
|
117
|
+
updated_fields.add("version")
|
|
118
|
+
|
|
119
|
+
current_revision = revision.get_revision()
|
|
120
|
+
if current_revision and current_revision != release.revision:
|
|
121
|
+
release.revision = current_revision
|
|
122
|
+
updated_fields.add("revision")
|
|
123
|
+
|
|
124
|
+
if updated_fields:
|
|
125
|
+
release.save(update_fields=list(updated_fields))
|
|
126
|
+
PackageRelease.dump_fixture()
|
|
127
|
+
|
|
128
|
+
return bool(updated_fields), previous_version
|
|
129
|
+
|
|
130
|
+
|
|
63
131
|
def _changelog_notes(version: str) -> str:
|
|
64
132
|
path = Path("CHANGELOG.rst")
|
|
65
133
|
if not path.exists():
|
|
@@ -85,6 +153,46 @@ class ApprovalRequired(Exception):
|
|
|
85
153
|
"""Raised when release manager approval is required before continuing."""
|
|
86
154
|
|
|
87
155
|
|
|
156
|
+
def _format_condition_failure(todo: Todo, result) -> str:
|
|
157
|
+
"""Return a localized error message for a failed TODO condition."""
|
|
158
|
+
|
|
159
|
+
if result.error and result.resolved:
|
|
160
|
+
detail = _("%(condition)s (error: %(error)s)") % {
|
|
161
|
+
"condition": result.resolved,
|
|
162
|
+
"error": result.error,
|
|
163
|
+
}
|
|
164
|
+
elif result.error:
|
|
165
|
+
detail = _("Error: %(error)s") % {"error": result.error}
|
|
166
|
+
elif result.resolved:
|
|
167
|
+
detail = result.resolved
|
|
168
|
+
else:
|
|
169
|
+
detail = _("Condition evaluated to False")
|
|
170
|
+
return _("Condition failed for %(todo)s: %(detail)s") % {
|
|
171
|
+
"todo": todo.request,
|
|
172
|
+
"detail": detail,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_return_url(request) -> str:
|
|
177
|
+
"""Return a safe URL to redirect back to after completing a TODO."""
|
|
178
|
+
|
|
179
|
+
candidates = [request.GET.get("next"), request.POST.get("next")]
|
|
180
|
+
referer = request.META.get("HTTP_REFERER")
|
|
181
|
+
if referer:
|
|
182
|
+
candidates.append(referer)
|
|
183
|
+
|
|
184
|
+
for candidate in candidates:
|
|
185
|
+
if not candidate:
|
|
186
|
+
continue
|
|
187
|
+
if url_has_allowed_host_and_scheme(
|
|
188
|
+
candidate,
|
|
189
|
+
allowed_hosts={request.get_host()},
|
|
190
|
+
require_https=request.is_secure(),
|
|
191
|
+
):
|
|
192
|
+
return candidate
|
|
193
|
+
return resolve_url("admin:index")
|
|
194
|
+
|
|
195
|
+
|
|
88
196
|
def _step_check_todos(release, ctx, log_path: Path) -> None:
|
|
89
197
|
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
90
198
|
if pending_qs.exists():
|
|
@@ -312,10 +420,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
|
|
|
312
420
|
_append_log(log_path, "Upload complete")
|
|
313
421
|
|
|
314
422
|
|
|
423
|
+
FIXTURE_REVIEW_STEP_NAME = "Freeze, squash and approve migrations"
|
|
424
|
+
|
|
425
|
+
|
|
315
426
|
PUBLISH_STEPS = [
|
|
316
427
|
("Check version number availability", _step_check_version),
|
|
317
428
|
("Confirm release TODO completion", _step_check_todos),
|
|
318
|
-
(
|
|
429
|
+
(FIXTURE_REVIEW_STEP_NAME, _step_handle_migrations),
|
|
319
430
|
("Compose CHANGELOG and documentation", _step_changelog_docs),
|
|
320
431
|
("Execute pre-release actions", _step_pre_release_actions),
|
|
321
432
|
("Build release artifacts", _step_promote_build),
|
|
@@ -514,12 +625,26 @@ def release_progress(request, pk: int, action: str):
|
|
|
514
625
|
release = get_object_or_404(PackageRelease, pk=pk)
|
|
515
626
|
if action != "publish":
|
|
516
627
|
raise Http404("Unknown action")
|
|
517
|
-
if not release.is_current:
|
|
518
|
-
raise Http404("Release is not current")
|
|
519
628
|
session_key = f"release_publish_{pk}"
|
|
520
629
|
lock_path = Path("locks") / f"release_publish_{pk}.json"
|
|
521
630
|
restart_path = Path("locks") / f"release_publish_{pk}.restarts"
|
|
522
631
|
|
|
632
|
+
if not release.is_current:
|
|
633
|
+
if release.is_published:
|
|
634
|
+
raise Http404("Release is not current")
|
|
635
|
+
updated, previous_version = _sync_release_with_revision(release)
|
|
636
|
+
if updated:
|
|
637
|
+
request.session.pop(session_key, None)
|
|
638
|
+
if lock_path.exists():
|
|
639
|
+
lock_path.unlink()
|
|
640
|
+
if restart_path.exists():
|
|
641
|
+
restart_path.unlink()
|
|
642
|
+
log_dir = Path("logs")
|
|
643
|
+
for log_file in log_dir.glob(
|
|
644
|
+
f"{release.package.name}-{previous_version}*.log"
|
|
645
|
+
):
|
|
646
|
+
log_file.unlink()
|
|
647
|
+
|
|
523
648
|
if request.GET.get("restart"):
|
|
524
649
|
count = 0
|
|
525
650
|
if restart_path.exists():
|
|
@@ -555,11 +680,11 @@ def release_progress(request, pk: int, action: str):
|
|
|
555
680
|
if credentials_ready and ctx.get("approval_credentials_missing"):
|
|
556
681
|
ctx.pop("approval_credentials_missing", None)
|
|
557
682
|
|
|
683
|
+
ack_todos_requested = bool(request.GET.get("ack_todos"))
|
|
684
|
+
|
|
558
685
|
if request.GET.get("start"):
|
|
559
686
|
ctx["started"] = True
|
|
560
687
|
ctx["paused"] = False
|
|
561
|
-
if request.GET.get("ack_todos"):
|
|
562
|
-
ctx["todos_ack"] = True
|
|
563
688
|
if (
|
|
564
689
|
ctx.get("awaiting_approval")
|
|
565
690
|
and not ctx.get("approval_credentials_missing")
|
|
@@ -580,9 +705,34 @@ def release_progress(request, pk: int, action: str):
|
|
|
580
705
|
step_count = ctx.get("step", 0)
|
|
581
706
|
step_param = request.GET.get("step")
|
|
582
707
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
708
|
+
pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
|
|
709
|
+
pending_items = list(pending_qs)
|
|
710
|
+
if ack_todos_requested:
|
|
711
|
+
if pending_items:
|
|
712
|
+
failures = []
|
|
713
|
+
for todo in pending_items:
|
|
714
|
+
result = todo.check_on_done_condition()
|
|
715
|
+
if not result.passed:
|
|
716
|
+
failures.append((todo, result))
|
|
717
|
+
if failures:
|
|
718
|
+
ctx.pop("todos_ack", None)
|
|
719
|
+
for todo, result in failures:
|
|
720
|
+
messages.error(request, _format_condition_failure(todo, result))
|
|
721
|
+
else:
|
|
722
|
+
ctx["todos_ack"] = True
|
|
723
|
+
else:
|
|
724
|
+
ctx["todos_ack"] = True
|
|
725
|
+
|
|
726
|
+
if pending_items and not ctx.get("todos_ack"):
|
|
727
|
+
ctx["todos"] = [
|
|
728
|
+
{
|
|
729
|
+
"id": todo.pk,
|
|
730
|
+
"request": todo.request,
|
|
731
|
+
"url": todo.url,
|
|
732
|
+
"request_details": todo.request_details,
|
|
733
|
+
}
|
|
734
|
+
for todo in pending_items
|
|
735
|
+
]
|
|
586
736
|
else:
|
|
587
737
|
ctx.pop("todos", None)
|
|
588
738
|
|
|
@@ -608,6 +758,14 @@ def release_progress(request, pk: int, action: str):
|
|
|
608
758
|
log_path.unlink()
|
|
609
759
|
|
|
610
760
|
steps = PUBLISH_STEPS
|
|
761
|
+
fixtures_step_index = next(
|
|
762
|
+
(
|
|
763
|
+
index
|
|
764
|
+
for index, (name, _) in enumerate(steps)
|
|
765
|
+
if name == FIXTURE_REVIEW_STEP_NAME
|
|
766
|
+
),
|
|
767
|
+
None,
|
|
768
|
+
)
|
|
611
769
|
error = ctx.get("error")
|
|
612
770
|
|
|
613
771
|
if (
|
|
@@ -742,6 +900,14 @@ def release_progress(request, pk: int, action: str):
|
|
|
742
900
|
"admin:core_user_change", args=[request.user.pk]
|
|
743
901
|
)
|
|
744
902
|
|
|
903
|
+
fixtures_summary = ctx.get("fixtures")
|
|
904
|
+
if (
|
|
905
|
+
fixtures_summary
|
|
906
|
+
and fixtures_step_index is not None
|
|
907
|
+
and step_count > fixtures_step_index
|
|
908
|
+
):
|
|
909
|
+
fixtures_summary = None
|
|
910
|
+
|
|
745
911
|
context = {
|
|
746
912
|
"release": release,
|
|
747
913
|
"action": "publish",
|
|
@@ -753,7 +919,7 @@ def release_progress(request, pk: int, action: str):
|
|
|
753
919
|
"log_content": log_content,
|
|
754
920
|
"log_path": str(log_path),
|
|
755
921
|
"cert_log": ctx.get("cert_log"),
|
|
756
|
-
"fixtures":
|
|
922
|
+
"fixtures": fixtures_summary,
|
|
757
923
|
"todos": ctx.get("todos"),
|
|
758
924
|
"restart_count": restart_count,
|
|
759
925
|
"started": ctx.get("started", False),
|
|
@@ -780,10 +946,86 @@ def release_progress(request, pk: int, action: str):
|
|
|
780
946
|
return render(request, "core/release_progress.html", context)
|
|
781
947
|
|
|
782
948
|
|
|
949
|
+
def _todo_iframe_url(request, todo: Todo) -> str:
|
|
950
|
+
"""Return a safe iframe URL for ``todo`` scoped to the current host."""
|
|
951
|
+
|
|
952
|
+
fallback = reverse("admin:core_todo_change", args=[todo.pk])
|
|
953
|
+
raw_url = (todo.url or "").strip()
|
|
954
|
+
if not raw_url:
|
|
955
|
+
return fallback
|
|
956
|
+
|
|
957
|
+
parsed = urlsplit(raw_url)
|
|
958
|
+
if not parsed.scheme and not parsed.netloc:
|
|
959
|
+
return raw_url
|
|
960
|
+
|
|
961
|
+
if parsed.scheme and parsed.scheme.lower() not in {"http", "https"}:
|
|
962
|
+
return fallback
|
|
963
|
+
|
|
964
|
+
request_host = request.get_host().strip().lower()
|
|
965
|
+
host_without_port = request_host.split(":", 1)[0]
|
|
966
|
+
allowed_hosts = {
|
|
967
|
+
request_host,
|
|
968
|
+
host_without_port,
|
|
969
|
+
"localhost",
|
|
970
|
+
"127.0.0.1",
|
|
971
|
+
"0.0.0.0",
|
|
972
|
+
"::1",
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
site_domain = ""
|
|
976
|
+
try:
|
|
977
|
+
site_domain = Site.objects.get_current().domain.strip().lower()
|
|
978
|
+
except Site.DoesNotExist:
|
|
979
|
+
site_domain = ""
|
|
980
|
+
if site_domain:
|
|
981
|
+
allowed_hosts.add(site_domain)
|
|
982
|
+
allowed_hosts.add(site_domain.split(":", 1)[0])
|
|
983
|
+
|
|
984
|
+
for host in getattr(settings, "ALLOWED_HOSTS", []):
|
|
985
|
+
if not isinstance(host, str):
|
|
986
|
+
continue
|
|
987
|
+
normalized = host.strip().lower()
|
|
988
|
+
if not normalized or normalized.startswith("*"):
|
|
989
|
+
continue
|
|
990
|
+
allowed_hosts.add(normalized)
|
|
991
|
+
allowed_hosts.add(normalized.split(":", 1)[0])
|
|
992
|
+
|
|
993
|
+
hostname = (parsed.hostname or "").strip().lower()
|
|
994
|
+
netloc = parsed.netloc.strip().lower()
|
|
995
|
+
if hostname in allowed_hosts or netloc in allowed_hosts:
|
|
996
|
+
path = parsed.path or "/"
|
|
997
|
+
if not path.startswith("/"):
|
|
998
|
+
path = f"/{path}"
|
|
999
|
+
return urlunsplit(("", "", path, parsed.query, parsed.fragment)) or fallback
|
|
1000
|
+
|
|
1001
|
+
return fallback
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
@staff_member_required
|
|
1005
|
+
def todo_focus(request, pk: int):
|
|
1006
|
+
todo = get_object_or_404(Todo, pk=pk, is_deleted=False)
|
|
1007
|
+
if todo.done_on:
|
|
1008
|
+
return redirect(_get_return_url(request))
|
|
1009
|
+
|
|
1010
|
+
iframe_url = _todo_iframe_url(request, todo)
|
|
1011
|
+
context = {
|
|
1012
|
+
"todo": todo,
|
|
1013
|
+
"iframe_url": iframe_url,
|
|
1014
|
+
"next_url": _get_return_url(request),
|
|
1015
|
+
"done_url": reverse("todo-done", args=[todo.pk]),
|
|
1016
|
+
}
|
|
1017
|
+
return render(request, "core/todo_focus.html", context)
|
|
1018
|
+
|
|
1019
|
+
|
|
783
1020
|
@staff_member_required
|
|
784
1021
|
@require_POST
|
|
785
1022
|
def todo_done(request, pk: int):
|
|
786
1023
|
todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
|
|
1024
|
+
redirect_to = _get_return_url(request)
|
|
1025
|
+
result = todo.check_on_done_condition()
|
|
1026
|
+
if not result.passed:
|
|
1027
|
+
messages.error(request, _format_condition_failure(todo, result))
|
|
1028
|
+
return redirect(redirect_to)
|
|
787
1029
|
todo.done_on = timezone.now()
|
|
788
1030
|
todo.save(update_fields=["done_on"])
|
|
789
|
-
return redirect(
|
|
1031
|
+
return redirect(redirect_to)
|
nodes/admin.py
CHANGED
|
@@ -9,6 +9,7 @@ from django.db.models import Count
|
|
|
9
9
|
from django.conf import settings
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from django.http import HttpResponse
|
|
12
|
+
from django.utils.translation import gettext_lazy as _
|
|
12
13
|
import base64
|
|
13
14
|
import pyperclip
|
|
14
15
|
from pyperclip import PyperclipException
|
|
@@ -114,6 +115,9 @@ class NodeAdmin(EntityModelAdmin):
|
|
|
114
115
|
|
|
115
116
|
token = uuid.uuid4().hex
|
|
116
117
|
context = {
|
|
118
|
+
**self.admin_site.each_context(request),
|
|
119
|
+
"opts": self.model._meta,
|
|
120
|
+
"title": _("Register Visitor Node"),
|
|
117
121
|
"token": token,
|
|
118
122
|
"info_url": reverse("node-info"),
|
|
119
123
|
"register_url": reverse("register-node"),
|
|
@@ -280,9 +284,6 @@ class EmailOutboxAdmin(EntityModelAdmin):
|
|
|
280
284
|
self.message_user(request, str(exc), messages.ERROR)
|
|
281
285
|
return redirect("..")
|
|
282
286
|
|
|
283
|
-
def get_model_perms(self, request): # pragma: no cover - hide from index
|
|
284
|
-
return {}
|
|
285
|
-
|
|
286
287
|
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
287
288
|
extra_context = extra_context or {}
|
|
288
289
|
if object_id:
|
|
@@ -430,7 +431,14 @@ class ContentSampleAdmin(EntityModelAdmin):
|
|
|
430
431
|
|
|
431
432
|
@admin.register(NetMessage)
|
|
432
433
|
class NetMessageAdmin(EntityModelAdmin):
|
|
433
|
-
list_display = (
|
|
434
|
+
list_display = (
|
|
435
|
+
"subject",
|
|
436
|
+
"body",
|
|
437
|
+
"reach",
|
|
438
|
+
"node_origin",
|
|
439
|
+
"created",
|
|
440
|
+
"complete",
|
|
441
|
+
)
|
|
434
442
|
search_fields = ("subject", "body")
|
|
435
443
|
list_filter = ("complete", "reach")
|
|
436
444
|
ordering = ("-created",)
|
nodes/backends.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
3
6
|
from django.core.mail.backends.base import BaseEmailBackend
|
|
4
7
|
from django.core.mail import get_connection
|
|
5
8
|
from django.conf import settings
|
|
@@ -13,41 +16,130 @@ class OutboxEmailBackend(BaseEmailBackend):
|
|
|
13
16
|
|
|
14
17
|
If a matching outbox exists for the message's ``from_email`` (matching
|
|
15
18
|
either ``from_email`` or ``username``), that outbox's SMTP credentials are
|
|
16
|
-
used.
|
|
17
|
-
|
|
19
|
+
used. ``EmailOutbox`` associations to ``node``, ``user`` and ``group`` are
|
|
20
|
+
also considered and preferred when multiple criteria match. When no
|
|
21
|
+
outboxes are configured, the system falls back to Django's default SMTP
|
|
22
|
+
settings.
|
|
18
23
|
"""
|
|
19
24
|
|
|
20
|
-
def
|
|
25
|
+
def _resolve_identifier(self, message, attr: str):
|
|
26
|
+
value = getattr(message, attr, None)
|
|
27
|
+
if value is None:
|
|
28
|
+
value = getattr(message, f"{attr}_id", None)
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
return getattr(value, "pk", value)
|
|
32
|
+
|
|
33
|
+
def _select_outbox(
|
|
34
|
+
self, message
|
|
35
|
+
) -> tuple[EmailOutbox | None, list[EmailOutbox]]:
|
|
36
|
+
from_email = getattr(message, "from_email", None)
|
|
37
|
+
node_id = self._resolve_identifier(message, "node")
|
|
38
|
+
user_id = self._resolve_identifier(message, "user")
|
|
39
|
+
group_id = self._resolve_identifier(message, "group")
|
|
40
|
+
|
|
41
|
+
match_sets: list[tuple[str, list[EmailOutbox]]] = []
|
|
42
|
+
|
|
21
43
|
if from_email:
|
|
22
|
-
|
|
44
|
+
email_matches = list(
|
|
23
45
|
EmailOutbox.objects.filter(
|
|
24
46
|
Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
|
|
25
|
-
)
|
|
26
|
-
or EmailOutbox.objects.first()
|
|
47
|
+
)
|
|
27
48
|
)
|
|
28
|
-
|
|
49
|
+
if email_matches:
|
|
50
|
+
match_sets.append(("from_email", email_matches))
|
|
51
|
+
|
|
52
|
+
if node_id:
|
|
53
|
+
node_matches = list(EmailOutbox.objects.filter(node_id=node_id))
|
|
54
|
+
if node_matches:
|
|
55
|
+
match_sets.append(("node", node_matches))
|
|
56
|
+
|
|
57
|
+
if user_id:
|
|
58
|
+
user_matches = list(EmailOutbox.objects.filter(user_id=user_id))
|
|
59
|
+
if user_matches:
|
|
60
|
+
match_sets.append(("user", user_matches))
|
|
61
|
+
|
|
62
|
+
if group_id:
|
|
63
|
+
group_matches = list(EmailOutbox.objects.filter(group_id=group_id))
|
|
64
|
+
if group_matches:
|
|
65
|
+
match_sets.append(("group", group_matches))
|
|
66
|
+
|
|
67
|
+
if not match_sets:
|
|
68
|
+
return EmailOutbox.objects.first(), []
|
|
69
|
+
|
|
70
|
+
candidates: dict[int, EmailOutbox] = {}
|
|
71
|
+
scores: defaultdict[int, int] = defaultdict(int)
|
|
72
|
+
|
|
73
|
+
for _, matches in match_sets:
|
|
74
|
+
for outbox in matches:
|
|
75
|
+
candidates[outbox.pk] = outbox
|
|
76
|
+
scores[outbox.pk] += 1
|
|
77
|
+
|
|
78
|
+
if not candidates:
|
|
79
|
+
return EmailOutbox.objects.first(), []
|
|
80
|
+
|
|
81
|
+
selected: EmailOutbox | None = None
|
|
82
|
+
fallbacks: list[EmailOutbox] = []
|
|
83
|
+
|
|
84
|
+
for score in sorted(set(scores.values()), reverse=True):
|
|
85
|
+
group = [candidates[pk] for pk, value in scores.items() if value == score]
|
|
86
|
+
if len(group) > 1:
|
|
87
|
+
random.shuffle(group)
|
|
88
|
+
if selected is None:
|
|
89
|
+
selected = group[0]
|
|
90
|
+
fallbacks.extend(group[1:])
|
|
91
|
+
else:
|
|
92
|
+
fallbacks.extend(group)
|
|
93
|
+
|
|
94
|
+
return selected, fallbacks
|
|
29
95
|
|
|
30
96
|
def send_messages(self, email_messages):
|
|
31
97
|
sent = 0
|
|
32
98
|
for message in email_messages:
|
|
33
|
-
|
|
99
|
+
original_from_email = message.from_email
|
|
100
|
+
outbox, fallbacks = self._select_outbox(message)
|
|
101
|
+
tried_outboxes = []
|
|
34
102
|
if outbox:
|
|
35
|
-
|
|
36
|
-
|
|
103
|
+
tried_outboxes.append(outbox)
|
|
104
|
+
tried_outboxes.extend(fallbacks)
|
|
105
|
+
|
|
106
|
+
last_error: Exception | None = None
|
|
107
|
+
|
|
108
|
+
if tried_outboxes:
|
|
109
|
+
for candidate in tried_outboxes:
|
|
110
|
+
connection = candidate.get_connection()
|
|
37
111
|
message.from_email = (
|
|
38
|
-
|
|
112
|
+
original_from_email
|
|
113
|
+
or candidate.from_email
|
|
114
|
+
or settings.DEFAULT_FROM_EMAIL
|
|
39
115
|
)
|
|
116
|
+
try:
|
|
117
|
+
sent += connection.send_messages([message]) or 0
|
|
118
|
+
last_error = None
|
|
119
|
+
break
|
|
120
|
+
except Exception as exc: # pragma: no cover - retry on error
|
|
121
|
+
last_error = exc
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
connection.close()
|
|
125
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
126
|
+
pass
|
|
127
|
+
if last_error is not None:
|
|
128
|
+
message.from_email = original_from_email
|
|
129
|
+
raise last_error
|
|
40
130
|
else:
|
|
41
131
|
connection = get_connection(
|
|
42
132
|
"django.core.mail.backends.smtp.EmailBackend"
|
|
43
133
|
)
|
|
44
134
|
if not message.from_email:
|
|
45
135
|
message.from_email = settings.DEFAULT_FROM_EMAIL
|
|
46
|
-
try:
|
|
47
|
-
sent += connection.send_messages([message]) or 0
|
|
48
|
-
finally:
|
|
49
136
|
try:
|
|
50
|
-
connection.
|
|
51
|
-
|
|
52
|
-
|
|
137
|
+
sent += connection.send_messages([message]) or 0
|
|
138
|
+
finally:
|
|
139
|
+
try:
|
|
140
|
+
connection.close()
|
|
141
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
message.from_email = original_from_email
|
|
53
145
|
return sent
|