arthexis 0.1.13__py3-none-any.whl → 0.1.14__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.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
- arthexis-0.1.14.dist-info/RECORD +109 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +43 -43
- config/auth_app.py +7 -7
- config/celery.py +32 -32
- config/context_processors.py +67 -69
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +25 -25
- config/offline.py +49 -49
- config/settings.py +691 -682
- config/settings_helpers.py +109 -109
- config/urls.py +171 -166
- config/wsgi.py +17 -17
- core/admin.py +3771 -2809
- core/admin_history.py +50 -50
- core/admindocs.py +151 -151
- core/apps.py +356 -272
- core/auto_upgrade.py +57 -57
- core/backends.py +265 -236
- core/changelog.py +342 -0
- core/entity.py +133 -133
- core/environment.py +61 -61
- core/fields.py +168 -168
- core/form_fields.py +75 -75
- core/github_helper.py +188 -25
- core/github_issues.py +178 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +100 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3609 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +721 -368
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -149
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +315 -315
- core/system.py +752 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2095 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2175 -1417
- core/widgets.py +213 -94
- core/workgroup_urls.py +17 -17
- core/workgroup_views.py +94 -94
- nodes/admin.py +1720 -1161
- nodes/apps.py +87 -85
- nodes/backends.py +160 -160
- nodes/dns.py +203 -203
- nodes/feature_checks.py +133 -133
- nodes/lcd.py +165 -165
- nodes/models.py +1737 -1597
- nodes/reports.py +411 -411
- nodes/rfid_sync.py +195 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +46 -46
- nodes/tests.py +3810 -3116
- nodes/urls.py +15 -14
- nodes/utils.py +121 -105
- nodes/views.py +683 -619
- ocpp/admin.py +948 -948
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1565 -1459
- ocpp/evcs.py +844 -844
- ocpp/evcs_discovery.py +158 -158
- ocpp/models.py +917 -917
- ocpp/reference_utils.py +42 -42
- ocpp/routing.py +11 -11
- ocpp/simulator.py +745 -745
- ocpp/status_display.py +26 -26
- ocpp/store.py +601 -541
- ocpp/tasks.py +31 -31
- ocpp/test_export_import.py +130 -130
- ocpp/test_rfid.py +913 -702
- ocpp/tests.py +4445 -4094
- ocpp/transactions_io.py +189 -189
- ocpp/urls.py +50 -50
- ocpp/views.py +1479 -1251
- pages/admin.py +708 -539
- pages/apps.py +10 -10
- pages/checks.py +40 -40
- pages/context_processors.py +127 -119
- pages/defaults.py +13 -13
- pages/forms.py +198 -198
- pages/middleware.py +205 -153
- pages/models.py +607 -426
- pages/tests.py +2612 -2200
- pages/urls.py +25 -25
- pages/utils.py +12 -12
- pages/views.py +1165 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/apps.py
CHANGED
|
@@ -1,85 +1,87 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import os
|
|
3
|
-
import socket
|
|
4
|
-
import threading
|
|
5
|
-
import time
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from django.apps import AppConfig
|
|
9
|
-
from django.conf import settings
|
|
10
|
-
from django.db import connections
|
|
11
|
-
from django.db.utils import OperationalError
|
|
12
|
-
from utils import revision
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _startup_notification() -> None:
|
|
19
|
-
"""Queue a Net Message with ``hostname:port`` and version info."""
|
|
20
|
-
|
|
21
|
-
host = socket.gethostname()
|
|
22
|
-
|
|
23
|
-
port = os.environ.get("PORT", "8000")
|
|
24
|
-
|
|
25
|
-
version = ""
|
|
26
|
-
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
27
|
-
if ver_path.exists():
|
|
28
|
-
version = ver_path.read_text().strip()
|
|
29
|
-
|
|
30
|
-
revision_value = revision.get_revision()
|
|
31
|
-
rev_short = revision_value[-6:] if revision_value else ""
|
|
32
|
-
|
|
33
|
-
body = version
|
|
34
|
-
if body:
|
|
35
|
-
normalized = body.lstrip("vV") or body
|
|
36
|
-
base_version = normalized.rstrip("+")
|
|
37
|
-
needs_marker = False
|
|
38
|
-
if base_version and revision_value:
|
|
39
|
-
try: # pragma: no cover - defensive guard
|
|
40
|
-
from core.models import PackageRelease
|
|
41
|
-
|
|
42
|
-
needs_marker = not PackageRelease.matches_revision(
|
|
43
|
-
base_version, revision_value
|
|
44
|
-
)
|
|
45
|
-
except Exception:
|
|
46
|
-
logger.debug("Startup release comparison failed", exc_info=True)
|
|
47
|
-
if needs_marker and not normalized.endswith("+"):
|
|
48
|
-
body = f"{body}+"
|
|
49
|
-
if rev_short:
|
|
50
|
-
body = f"{body} r{rev_short}" if body else f"r{rev_short}"
|
|
51
|
-
|
|
52
|
-
def _worker() -> None: # pragma: no cover - background thread
|
|
53
|
-
# Allow the LCD a moment to become ready and retry a few times
|
|
54
|
-
for _ in range(5):
|
|
55
|
-
try:
|
|
56
|
-
from nodes.models import NetMessage
|
|
57
|
-
|
|
58
|
-
NetMessage.broadcast(subject=f"{host}:{port}", body=body)
|
|
59
|
-
break
|
|
60
|
-
except Exception:
|
|
61
|
-
time.sleep(1)
|
|
62
|
-
|
|
63
|
-
threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _trigger_startup_notification(**_: object) -> None:
|
|
67
|
-
"""Attempt to send the startup notification in the background."""
|
|
68
|
-
|
|
69
|
-
try:
|
|
70
|
-
connections["default"].ensure_connection()
|
|
71
|
-
except OperationalError:
|
|
72
|
-
logger.exception("Startup notification skipped: database unavailable")
|
|
73
|
-
return
|
|
74
|
-
_startup_notification()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class NodesConfig(AppConfig):
|
|
78
|
-
default_auto_field = "django.db.models.BigAutoField"
|
|
79
|
-
name = "nodes"
|
|
80
|
-
verbose_name = "4. Infrastructure"
|
|
81
|
-
|
|
82
|
-
def ready(self): # pragma: no cover - exercised on app start
|
|
83
|
-
from django.db.models.signals import post_migrate
|
|
84
|
-
|
|
85
|
-
post_migrate.connect(_trigger_startup_notification, sender=self)
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.db import connections
|
|
11
|
+
from django.db.utils import OperationalError
|
|
12
|
+
from utils import revision
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _startup_notification() -> None:
|
|
19
|
+
"""Queue a Net Message with ``hostname:port`` and version info."""
|
|
20
|
+
|
|
21
|
+
host = socket.gethostname()
|
|
22
|
+
|
|
23
|
+
port = os.environ.get("PORT", "8000")
|
|
24
|
+
|
|
25
|
+
version = ""
|
|
26
|
+
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
27
|
+
if ver_path.exists():
|
|
28
|
+
version = ver_path.read_text().strip()
|
|
29
|
+
|
|
30
|
+
revision_value = revision.get_revision()
|
|
31
|
+
rev_short = revision_value[-6:] if revision_value else ""
|
|
32
|
+
|
|
33
|
+
body = version
|
|
34
|
+
if body:
|
|
35
|
+
normalized = body.lstrip("vV") or body
|
|
36
|
+
base_version = normalized.rstrip("+")
|
|
37
|
+
needs_marker = False
|
|
38
|
+
if base_version and revision_value:
|
|
39
|
+
try: # pragma: no cover - defensive guard
|
|
40
|
+
from core.models import PackageRelease
|
|
41
|
+
|
|
42
|
+
needs_marker = not PackageRelease.matches_revision(
|
|
43
|
+
base_version, revision_value
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
logger.debug("Startup release comparison failed", exc_info=True)
|
|
47
|
+
if needs_marker and not normalized.endswith("+"):
|
|
48
|
+
body = f"{body}+"
|
|
49
|
+
if rev_short:
|
|
50
|
+
body = f"{body} r{rev_short}" if body else f"r{rev_short}"
|
|
51
|
+
|
|
52
|
+
def _worker() -> None: # pragma: no cover - background thread
|
|
53
|
+
# Allow the LCD a moment to become ready and retry a few times
|
|
54
|
+
for _ in range(5):
|
|
55
|
+
try:
|
|
56
|
+
from nodes.models import NetMessage
|
|
57
|
+
|
|
58
|
+
NetMessage.broadcast(subject=f"{host}:{port}", body=body)
|
|
59
|
+
break
|
|
60
|
+
except Exception:
|
|
61
|
+
time.sleep(1)
|
|
62
|
+
|
|
63
|
+
threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _trigger_startup_notification(**_: object) -> None:
|
|
67
|
+
"""Attempt to send the startup notification in the background."""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
connections["default"].ensure_connection()
|
|
71
|
+
except OperationalError:
|
|
72
|
+
logger.exception("Startup notification skipped: database unavailable")
|
|
73
|
+
return
|
|
74
|
+
_startup_notification()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NodesConfig(AppConfig):
|
|
78
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
79
|
+
name = "nodes"
|
|
80
|
+
verbose_name = "4. Infrastructure"
|
|
81
|
+
|
|
82
|
+
def ready(self): # pragma: no cover - exercised on app start
|
|
83
|
+
from django.db.models.signals import post_migrate
|
|
84
|
+
|
|
85
|
+
post_migrate.connect(_trigger_startup_notification, sender=self)
|
|
86
|
+
# Import signal handlers for content classifiers
|
|
87
|
+
from . import signals # noqa: F401
|
nodes/backends.py
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import random
|
|
4
|
-
from collections import defaultdict
|
|
5
|
-
|
|
6
|
-
from django.core.mail.backends.base import BaseEmailBackend
|
|
7
|
-
from django.core.mail import get_connection
|
|
8
|
-
from django.conf import settings
|
|
9
|
-
from django.db.models import Q
|
|
10
|
-
|
|
11
|
-
from .models import EmailOutbox
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class OutboxEmailBackend(BaseEmailBackend):
|
|
15
|
-
"""Email backend that selects an :class:`EmailOutbox` automatically.
|
|
16
|
-
|
|
17
|
-
If a matching outbox exists for the message's ``from_email`` (matching
|
|
18
|
-
either ``from_email`` or ``username``), that outbox's SMTP credentials are
|
|
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.
|
|
23
|
-
"""
|
|
24
|
-
|
|
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
|
-
enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
|
|
42
|
-
match_sets: list[tuple[str, list[EmailOutbox]]] = []
|
|
43
|
-
|
|
44
|
-
if from_email:
|
|
45
|
-
email_matches = list(
|
|
46
|
-
enabled_outboxes.filter(
|
|
47
|
-
Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
|
|
48
|
-
)
|
|
49
|
-
)
|
|
50
|
-
if email_matches:
|
|
51
|
-
match_sets.append(("from_email", email_matches))
|
|
52
|
-
|
|
53
|
-
if node_id:
|
|
54
|
-
node_matches = list(enabled_outboxes.filter(node_id=node_id))
|
|
55
|
-
if node_matches:
|
|
56
|
-
match_sets.append(("node", node_matches))
|
|
57
|
-
|
|
58
|
-
if user_id:
|
|
59
|
-
user_matches = list(enabled_outboxes.filter(user_id=user_id))
|
|
60
|
-
if user_matches:
|
|
61
|
-
match_sets.append(("user", user_matches))
|
|
62
|
-
|
|
63
|
-
if group_id:
|
|
64
|
-
group_matches = list(enabled_outboxes.filter(group_id=group_id))
|
|
65
|
-
if group_matches:
|
|
66
|
-
match_sets.append(("group", group_matches))
|
|
67
|
-
|
|
68
|
-
if not match_sets:
|
|
69
|
-
fallback = self._fallback_outbox(enabled_outboxes)
|
|
70
|
-
if fallback:
|
|
71
|
-
return fallback, []
|
|
72
|
-
return None, []
|
|
73
|
-
|
|
74
|
-
candidates: dict[int, EmailOutbox] = {}
|
|
75
|
-
scores: defaultdict[int, int] = defaultdict(int)
|
|
76
|
-
|
|
77
|
-
for _, matches in match_sets:
|
|
78
|
-
for outbox in matches:
|
|
79
|
-
candidates[outbox.pk] = outbox
|
|
80
|
-
scores[outbox.pk] += 1
|
|
81
|
-
|
|
82
|
-
if not candidates:
|
|
83
|
-
fallback = self._fallback_outbox(enabled_outboxes)
|
|
84
|
-
if fallback:
|
|
85
|
-
return fallback, []
|
|
86
|
-
return None, []
|
|
87
|
-
|
|
88
|
-
selected: EmailOutbox | None = None
|
|
89
|
-
fallbacks: list[EmailOutbox] = []
|
|
90
|
-
|
|
91
|
-
for score in sorted(set(scores.values()), reverse=True):
|
|
92
|
-
group = [candidates[pk] for pk, value in scores.items() if value == score]
|
|
93
|
-
if len(group) > 1:
|
|
94
|
-
random.shuffle(group)
|
|
95
|
-
if selected is None:
|
|
96
|
-
selected = group[0]
|
|
97
|
-
fallbacks.extend(group[1:])
|
|
98
|
-
else:
|
|
99
|
-
fallbacks.extend(group)
|
|
100
|
-
|
|
101
|
-
return selected, fallbacks
|
|
102
|
-
|
|
103
|
-
def _fallback_outbox(self, queryset):
|
|
104
|
-
ownerless = queryset.filter(
|
|
105
|
-
node__isnull=True, user__isnull=True, group__isnull=True
|
|
106
|
-
).order_by("pk").first()
|
|
107
|
-
if ownerless:
|
|
108
|
-
return ownerless
|
|
109
|
-
return queryset.order_by("pk").first()
|
|
110
|
-
|
|
111
|
-
def send_messages(self, email_messages):
|
|
112
|
-
sent = 0
|
|
113
|
-
for message in email_messages:
|
|
114
|
-
original_from_email = message.from_email
|
|
115
|
-
outbox, fallbacks = self._select_outbox(message)
|
|
116
|
-
tried_outboxes = []
|
|
117
|
-
if outbox:
|
|
118
|
-
tried_outboxes.append(outbox)
|
|
119
|
-
tried_outboxes.extend(fallbacks)
|
|
120
|
-
|
|
121
|
-
last_error: Exception | None = None
|
|
122
|
-
|
|
123
|
-
if tried_outboxes:
|
|
124
|
-
for candidate in tried_outboxes:
|
|
125
|
-
connection = candidate.get_connection()
|
|
126
|
-
message.from_email = (
|
|
127
|
-
original_from_email
|
|
128
|
-
or candidate.from_email
|
|
129
|
-
or settings.DEFAULT_FROM_EMAIL
|
|
130
|
-
)
|
|
131
|
-
try:
|
|
132
|
-
sent += connection.send_messages([message]) or 0
|
|
133
|
-
last_error = None
|
|
134
|
-
break
|
|
135
|
-
except Exception as exc: # pragma: no cover - retry on error
|
|
136
|
-
last_error = exc
|
|
137
|
-
finally:
|
|
138
|
-
try:
|
|
139
|
-
connection.close()
|
|
140
|
-
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
141
|
-
pass
|
|
142
|
-
if last_error is not None:
|
|
143
|
-
message.from_email = original_from_email
|
|
144
|
-
raise last_error
|
|
145
|
-
else:
|
|
146
|
-
connection = get_connection(
|
|
147
|
-
"django.core.mail.backends.smtp.EmailBackend"
|
|
148
|
-
)
|
|
149
|
-
if not message.from_email:
|
|
150
|
-
message.from_email = settings.DEFAULT_FROM_EMAIL
|
|
151
|
-
try:
|
|
152
|
-
sent += connection.send_messages([message]) or 0
|
|
153
|
-
finally:
|
|
154
|
-
try:
|
|
155
|
-
connection.close()
|
|
156
|
-
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
157
|
-
pass
|
|
158
|
-
|
|
159
|
-
message.from_email = original_from_email
|
|
160
|
-
return sent
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from django.core.mail.backends.base import BaseEmailBackend
|
|
7
|
+
from django.core.mail import get_connection
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.db.models import Q
|
|
10
|
+
|
|
11
|
+
from .models import EmailOutbox
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OutboxEmailBackend(BaseEmailBackend):
|
|
15
|
+
"""Email backend that selects an :class:`EmailOutbox` automatically.
|
|
16
|
+
|
|
17
|
+
If a matching outbox exists for the message's ``from_email`` (matching
|
|
18
|
+
either ``from_email`` or ``username``), that outbox's SMTP credentials are
|
|
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.
|
|
23
|
+
"""
|
|
24
|
+
|
|
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
|
+
enabled_outboxes = EmailOutbox.objects.filter(is_enabled=True)
|
|
42
|
+
match_sets: list[tuple[str, list[EmailOutbox]]] = []
|
|
43
|
+
|
|
44
|
+
if from_email:
|
|
45
|
+
email_matches = list(
|
|
46
|
+
enabled_outboxes.filter(
|
|
47
|
+
Q(from_email__iexact=from_email) | Q(username__iexact=from_email)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
if email_matches:
|
|
51
|
+
match_sets.append(("from_email", email_matches))
|
|
52
|
+
|
|
53
|
+
if node_id:
|
|
54
|
+
node_matches = list(enabled_outboxes.filter(node_id=node_id))
|
|
55
|
+
if node_matches:
|
|
56
|
+
match_sets.append(("node", node_matches))
|
|
57
|
+
|
|
58
|
+
if user_id:
|
|
59
|
+
user_matches = list(enabled_outboxes.filter(user_id=user_id))
|
|
60
|
+
if user_matches:
|
|
61
|
+
match_sets.append(("user", user_matches))
|
|
62
|
+
|
|
63
|
+
if group_id:
|
|
64
|
+
group_matches = list(enabled_outboxes.filter(group_id=group_id))
|
|
65
|
+
if group_matches:
|
|
66
|
+
match_sets.append(("group", group_matches))
|
|
67
|
+
|
|
68
|
+
if not match_sets:
|
|
69
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
70
|
+
if fallback:
|
|
71
|
+
return fallback, []
|
|
72
|
+
return None, []
|
|
73
|
+
|
|
74
|
+
candidates: dict[int, EmailOutbox] = {}
|
|
75
|
+
scores: defaultdict[int, int] = defaultdict(int)
|
|
76
|
+
|
|
77
|
+
for _, matches in match_sets:
|
|
78
|
+
for outbox in matches:
|
|
79
|
+
candidates[outbox.pk] = outbox
|
|
80
|
+
scores[outbox.pk] += 1
|
|
81
|
+
|
|
82
|
+
if not candidates:
|
|
83
|
+
fallback = self._fallback_outbox(enabled_outboxes)
|
|
84
|
+
if fallback:
|
|
85
|
+
return fallback, []
|
|
86
|
+
return None, []
|
|
87
|
+
|
|
88
|
+
selected: EmailOutbox | None = None
|
|
89
|
+
fallbacks: list[EmailOutbox] = []
|
|
90
|
+
|
|
91
|
+
for score in sorted(set(scores.values()), reverse=True):
|
|
92
|
+
group = [candidates[pk] for pk, value in scores.items() if value == score]
|
|
93
|
+
if len(group) > 1:
|
|
94
|
+
random.shuffle(group)
|
|
95
|
+
if selected is None:
|
|
96
|
+
selected = group[0]
|
|
97
|
+
fallbacks.extend(group[1:])
|
|
98
|
+
else:
|
|
99
|
+
fallbacks.extend(group)
|
|
100
|
+
|
|
101
|
+
return selected, fallbacks
|
|
102
|
+
|
|
103
|
+
def _fallback_outbox(self, queryset):
|
|
104
|
+
ownerless = queryset.filter(
|
|
105
|
+
node__isnull=True, user__isnull=True, group__isnull=True
|
|
106
|
+
).order_by("pk").first()
|
|
107
|
+
if ownerless:
|
|
108
|
+
return ownerless
|
|
109
|
+
return queryset.order_by("pk").first()
|
|
110
|
+
|
|
111
|
+
def send_messages(self, email_messages):
|
|
112
|
+
sent = 0
|
|
113
|
+
for message in email_messages:
|
|
114
|
+
original_from_email = message.from_email
|
|
115
|
+
outbox, fallbacks = self._select_outbox(message)
|
|
116
|
+
tried_outboxes = []
|
|
117
|
+
if outbox:
|
|
118
|
+
tried_outboxes.append(outbox)
|
|
119
|
+
tried_outboxes.extend(fallbacks)
|
|
120
|
+
|
|
121
|
+
last_error: Exception | None = None
|
|
122
|
+
|
|
123
|
+
if tried_outboxes:
|
|
124
|
+
for candidate in tried_outboxes:
|
|
125
|
+
connection = candidate.get_connection()
|
|
126
|
+
message.from_email = (
|
|
127
|
+
original_from_email
|
|
128
|
+
or candidate.from_email
|
|
129
|
+
or settings.DEFAULT_FROM_EMAIL
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
sent += connection.send_messages([message]) or 0
|
|
133
|
+
last_error = None
|
|
134
|
+
break
|
|
135
|
+
except Exception as exc: # pragma: no cover - retry on error
|
|
136
|
+
last_error = exc
|
|
137
|
+
finally:
|
|
138
|
+
try:
|
|
139
|
+
connection.close()
|
|
140
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
141
|
+
pass
|
|
142
|
+
if last_error is not None:
|
|
143
|
+
message.from_email = original_from_email
|
|
144
|
+
raise last_error
|
|
145
|
+
else:
|
|
146
|
+
connection = get_connection(
|
|
147
|
+
"django.core.mail.backends.smtp.EmailBackend"
|
|
148
|
+
)
|
|
149
|
+
if not message.from_email:
|
|
150
|
+
message.from_email = settings.DEFAULT_FROM_EMAIL
|
|
151
|
+
try:
|
|
152
|
+
sent += connection.send_messages([message]) or 0
|
|
153
|
+
finally:
|
|
154
|
+
try:
|
|
155
|
+
connection.close()
|
|
156
|
+
except Exception: # pragma: no cover - close errors shouldn't fail send
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
message.from_email = original_from_email
|
|
160
|
+
return sent
|