arthexis 0.1.13__py3-none-any.whl → 0.1.15__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.
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
- arthexis-0.1.15.dist-info/RECORD +110 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.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 +3795 -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 +149 -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 +114 -100
- core/mailer.py +85 -85
- core/middleware.py +91 -91
- core/models.py +3637 -2795
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +108 -108
- core/release.py +840 -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 +952 -493
- core/tasks.py +408 -394
- core/temp_passwords.py +181 -181
- core/test_system_info.py +186 -139
- core/tests.py +2168 -1521
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +641 -633
- core/views.py +2201 -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 +1764 -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 +3830 -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 +769 -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 +209 -153
- pages/models.py +643 -426
- pages/tasks.py +74 -0
- pages/tests.py +3025 -2200
- pages/urls.py +26 -25
- pages/utils.py +23 -12
- pages/views.py +1176 -1128
- arthexis-0.1.13.dist-info/RECORD +0 -105
- nodes/actions.py +0 -70
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
- {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
core/lcd_screen.py
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
"""Standalone LCD screen updater.
|
|
2
|
-
|
|
3
|
-
The script polls ``locks/lcd_screen.lck`` for up to two lines of text and
|
|
4
|
-
writes them to the attached LCD1602 display. If either line exceeds 16
|
|
5
|
-
characters the text scrolls horizontally. A third line in the lock file
|
|
6
|
-
can define the scroll speed in milliseconds per character (default 1000
|
|
7
|
-
ms).
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import logging
|
|
13
|
-
import time
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
from nodes.lcd import CharLCD1602, LCDUnavailableError
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger(__name__)
|
|
19
|
-
|
|
20
|
-
LOCK_FILE = Path(__file__).resolve().parents[1] / "locks" / "lcd_screen.lck"
|
|
21
|
-
DEFAULT_SCROLL_MS = 1000
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _read_lock_file() -> tuple[str, str, int]:
|
|
25
|
-
try:
|
|
26
|
-
lines = LOCK_FILE.read_text(encoding="utf-8").splitlines()
|
|
27
|
-
except FileNotFoundError:
|
|
28
|
-
return "", "", DEFAULT_SCROLL_MS
|
|
29
|
-
line1 = lines[0][:64] if len(lines) > 0 else ""
|
|
30
|
-
line2 = lines[1][:64] if len(lines) > 1 else ""
|
|
31
|
-
try:
|
|
32
|
-
speed = int(lines[2]) if len(lines) > 2 else DEFAULT_SCROLL_MS
|
|
33
|
-
except ValueError:
|
|
34
|
-
speed = DEFAULT_SCROLL_MS
|
|
35
|
-
return line1, line2, speed
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _display(lcd: CharLCD1602, line1: str, line2: str, scroll_ms: int) -> None:
|
|
39
|
-
scroll_sec = max(scroll_ms, 0) / 1000.0
|
|
40
|
-
text1 = line1[:64]
|
|
41
|
-
text2 = line2[:64]
|
|
42
|
-
pad1 = text1 + " " * 16 if len(text1) > 16 else text1.ljust(16)
|
|
43
|
-
pad2 = text2 + " " * 16 if len(text2) > 16 else text2.ljust(16)
|
|
44
|
-
steps = max(len(pad1) - 15, len(pad2) - 15)
|
|
45
|
-
for i in range(steps):
|
|
46
|
-
segment1 = pad1[i : i + 16]
|
|
47
|
-
segment2 = pad2[i : i + 16]
|
|
48
|
-
lcd.write(0, 0, segment1.ljust(16))
|
|
49
|
-
lcd.write(0, 1, segment2.ljust(16))
|
|
50
|
-
time.sleep(scroll_sec)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def main() -> None: # pragma: no cover - hardware dependent
|
|
54
|
-
lcd = None
|
|
55
|
-
last_mtime = 0.0
|
|
56
|
-
while True:
|
|
57
|
-
try:
|
|
58
|
-
if LOCK_FILE.exists():
|
|
59
|
-
mtime = LOCK_FILE.stat().st_mtime
|
|
60
|
-
if mtime != last_mtime or lcd is None:
|
|
61
|
-
line1, line2, speed = _read_lock_file()
|
|
62
|
-
if lcd is None:
|
|
63
|
-
lcd = CharLCD1602()
|
|
64
|
-
lcd.init_lcd()
|
|
65
|
-
lcd.clear()
|
|
66
|
-
_display(lcd, line1, line2, speed)
|
|
67
|
-
last_mtime = mtime
|
|
68
|
-
except LCDUnavailableError as exc:
|
|
69
|
-
logger.warning("LCD unavailable: %s", exc)
|
|
70
|
-
lcd = None
|
|
71
|
-
except Exception as exc:
|
|
72
|
-
logger.warning("LCD update failed: %s", exc)
|
|
73
|
-
lcd = None
|
|
74
|
-
time.sleep(0.5)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__": # pragma: no cover - script entry point
|
|
78
|
-
main()
|
|
1
|
+
"""Standalone LCD screen updater.
|
|
2
|
+
|
|
3
|
+
The script polls ``locks/lcd_screen.lck`` for up to two lines of text and
|
|
4
|
+
writes them to the attached LCD1602 display. If either line exceeds 16
|
|
5
|
+
characters the text scrolls horizontally. A third line in the lock file
|
|
6
|
+
can define the scroll speed in milliseconds per character (default 1000
|
|
7
|
+
ms).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from nodes.lcd import CharLCD1602, LCDUnavailableError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
LOCK_FILE = Path(__file__).resolve().parents[1] / "locks" / "lcd_screen.lck"
|
|
21
|
+
DEFAULT_SCROLL_MS = 1000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_lock_file() -> tuple[str, str, int]:
|
|
25
|
+
try:
|
|
26
|
+
lines = LOCK_FILE.read_text(encoding="utf-8").splitlines()
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return "", "", DEFAULT_SCROLL_MS
|
|
29
|
+
line1 = lines[0][:64] if len(lines) > 0 else ""
|
|
30
|
+
line2 = lines[1][:64] if len(lines) > 1 else ""
|
|
31
|
+
try:
|
|
32
|
+
speed = int(lines[2]) if len(lines) > 2 else DEFAULT_SCROLL_MS
|
|
33
|
+
except ValueError:
|
|
34
|
+
speed = DEFAULT_SCROLL_MS
|
|
35
|
+
return line1, line2, speed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _display(lcd: CharLCD1602, line1: str, line2: str, scroll_ms: int) -> None:
|
|
39
|
+
scroll_sec = max(scroll_ms, 0) / 1000.0
|
|
40
|
+
text1 = line1[:64]
|
|
41
|
+
text2 = line2[:64]
|
|
42
|
+
pad1 = text1 + " " * 16 if len(text1) > 16 else text1.ljust(16)
|
|
43
|
+
pad2 = text2 + " " * 16 if len(text2) > 16 else text2.ljust(16)
|
|
44
|
+
steps = max(len(pad1) - 15, len(pad2) - 15)
|
|
45
|
+
for i in range(steps):
|
|
46
|
+
segment1 = pad1[i : i + 16]
|
|
47
|
+
segment2 = pad2[i : i + 16]
|
|
48
|
+
lcd.write(0, 0, segment1.ljust(16))
|
|
49
|
+
lcd.write(0, 1, segment2.ljust(16))
|
|
50
|
+
time.sleep(scroll_sec)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None: # pragma: no cover - hardware dependent
|
|
54
|
+
lcd = None
|
|
55
|
+
last_mtime = 0.0
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
if LOCK_FILE.exists():
|
|
59
|
+
mtime = LOCK_FILE.stat().st_mtime
|
|
60
|
+
if mtime != last_mtime or lcd is None:
|
|
61
|
+
line1, line2, speed = _read_lock_file()
|
|
62
|
+
if lcd is None:
|
|
63
|
+
lcd = CharLCD1602()
|
|
64
|
+
lcd.init_lcd()
|
|
65
|
+
lcd.clear()
|
|
66
|
+
_display(lcd, line1, line2, speed)
|
|
67
|
+
last_mtime = mtime
|
|
68
|
+
except LCDUnavailableError as exc:
|
|
69
|
+
logger.warning("LCD unavailable: %s", exc)
|
|
70
|
+
lcd = None
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.warning("LCD update failed: %s", exc)
|
|
73
|
+
lcd = None
|
|
74
|
+
time.sleep(0.5)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__": # pragma: no cover - script entry point
|
|
78
|
+
main()
|
core/liveupdate.py
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
from functools import wraps
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def live_update(interval=5):
|
|
5
|
-
"""Decorator to mark function-based views for automatic refresh."""
|
|
6
|
-
|
|
7
|
-
def decorator(view):
|
|
8
|
-
@wraps(view)
|
|
9
|
-
def wrapped(request, *args, **kwargs):
|
|
10
|
-
setattr(request, "live_update_interval", interval)
|
|
11
|
-
return view(request, *args, **kwargs)
|
|
12
|
-
|
|
13
|
-
return wrapped
|
|
14
|
-
|
|
15
|
-
return decorator
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class LiveUpdateMixin:
|
|
19
|
-
"""Mixin to enable automatic refresh for class-based views."""
|
|
20
|
-
|
|
21
|
-
live_update_interval = 5
|
|
22
|
-
|
|
23
|
-
def dispatch(self, request, *args, **kwargs):
|
|
24
|
-
setattr(request, "live_update_interval", self.live_update_interval)
|
|
25
|
-
return super().dispatch(request, *args, **kwargs)
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def live_update(interval=5):
|
|
5
|
+
"""Decorator to mark function-based views for automatic refresh."""
|
|
6
|
+
|
|
7
|
+
def decorator(view):
|
|
8
|
+
@wraps(view)
|
|
9
|
+
def wrapped(request, *args, **kwargs):
|
|
10
|
+
setattr(request, "live_update_interval", interval)
|
|
11
|
+
return view(request, *args, **kwargs)
|
|
12
|
+
|
|
13
|
+
return wrapped
|
|
14
|
+
|
|
15
|
+
return decorator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LiveUpdateMixin:
|
|
19
|
+
"""Mixin to enable automatic refresh for class-based views."""
|
|
20
|
+
|
|
21
|
+
live_update_interval = 5
|
|
22
|
+
|
|
23
|
+
def dispatch(self, request, *args, **kwargs):
|
|
24
|
+
setattr(request, "live_update_interval", self.live_update_interval)
|
|
25
|
+
return super().dispatch(request, *args, **kwargs)
|
core/log_paths.py
CHANGED
|
@@ -1,100 +1,114 @@
|
|
|
1
|
-
"""Helpers for selecting writable log directories."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
candidates.append(Path("/
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
1
|
+
"""Helpers for selecting writable log directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_root() -> bool:
|
|
12
|
+
if hasattr(os, "geteuid"):
|
|
13
|
+
try:
|
|
14
|
+
return os.geteuid() == 0
|
|
15
|
+
except OSError: # pragma: no cover - defensive for unusual platforms
|
|
16
|
+
return False
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _state_home(base_home: Path) -> Path:
|
|
21
|
+
state_home = os.environ.get("XDG_STATE_HOME")
|
|
22
|
+
if state_home:
|
|
23
|
+
return Path(state_home).expanduser()
|
|
24
|
+
return base_home / ".local" / "state"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def select_log_dir(base_dir: Path) -> Path:
|
|
28
|
+
"""Choose a writable log directory for the current process."""
|
|
29
|
+
|
|
30
|
+
default = base_dir / "logs"
|
|
31
|
+
env_override = os.environ.get("ARTHEXIS_LOG_DIR")
|
|
32
|
+
is_root = _is_root()
|
|
33
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
34
|
+
|
|
35
|
+
candidates: list[Path] = []
|
|
36
|
+
if env_override:
|
|
37
|
+
candidates.append(Path(env_override).expanduser())
|
|
38
|
+
|
|
39
|
+
if is_root:
|
|
40
|
+
if not sudo_user or sudo_user == "root":
|
|
41
|
+
candidates.append(default)
|
|
42
|
+
candidates.append(Path("/var/log/arthexis"))
|
|
43
|
+
candidates.append(Path("/tmp/arthexis/logs"))
|
|
44
|
+
else:
|
|
45
|
+
home: Path | None
|
|
46
|
+
try:
|
|
47
|
+
home = Path.home()
|
|
48
|
+
except (RuntimeError, OSError, KeyError):
|
|
49
|
+
home = None
|
|
50
|
+
|
|
51
|
+
candidates.append(default)
|
|
52
|
+
|
|
53
|
+
tmp_logs = Path(tempfile.gettempdir()) / "arthexis" / "logs"
|
|
54
|
+
|
|
55
|
+
if home is not None:
|
|
56
|
+
state_home = _state_home(home)
|
|
57
|
+
candidates.extend(
|
|
58
|
+
[
|
|
59
|
+
state_home / "arthexis" / "logs",
|
|
60
|
+
home / ".arthexis" / "logs",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
candidates.append(tmp_logs)
|
|
65
|
+
|
|
66
|
+
candidates.append(Path("/tmp/arthexis/logs"))
|
|
67
|
+
candidates.append(tmp_logs)
|
|
68
|
+
|
|
69
|
+
seen: set[Path] = set()
|
|
70
|
+
ordered_candidates: list[Path] = []
|
|
71
|
+
for candidate in candidates:
|
|
72
|
+
candidate = candidate.expanduser()
|
|
73
|
+
if candidate not in seen:
|
|
74
|
+
seen.add(candidate)
|
|
75
|
+
ordered_candidates.append(candidate)
|
|
76
|
+
|
|
77
|
+
attempted: list[Path] = []
|
|
78
|
+
chosen: Path | None = None
|
|
79
|
+
for candidate in ordered_candidates:
|
|
80
|
+
attempted.append(candidate)
|
|
81
|
+
try:
|
|
82
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
if os.access(candidate, os.W_OK | os.X_OK):
|
|
86
|
+
chosen = candidate
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
if chosen is None:
|
|
90
|
+
attempted_str = (
|
|
91
|
+
", ".join(str(path) for path in attempted) if attempted else "none"
|
|
92
|
+
)
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Unable to create a writable log directory. Tried: {attempted_str}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if chosen != default:
|
|
98
|
+
if (
|
|
99
|
+
attempted
|
|
100
|
+
and attempted[0] == default
|
|
101
|
+
and not os.access(default, os.W_OK | os.X_OK)
|
|
102
|
+
):
|
|
103
|
+
print(
|
|
104
|
+
f"Log directory {default} is not writable; using {chosen}",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
107
|
+
elif is_root and sudo_user and sudo_user != "root" and not env_override:
|
|
108
|
+
print(
|
|
109
|
+
f"Running with elevated privileges; writing logs to {chosen}",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
os.environ["ARTHEXIS_LOG_DIR"] = str(chosen)
|
|
114
|
+
return chosen
|
core/mailer.py
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from typing import Sequence
|
|
3
|
-
|
|
4
|
-
from django.conf import settings
|
|
5
|
-
from django.core.mail import EmailMessage
|
|
6
|
-
from django.utils.module_loading import import_string
|
|
7
|
-
|
|
8
|
-
try: # pragma: no cover - import should always succeed but guard defensively
|
|
9
|
-
from django.core.mail.backends.dummy import (
|
|
10
|
-
EmailBackend as DummyEmailBackend,
|
|
11
|
-
)
|
|
12
|
-
except Exception: # pragma: no cover - fallback when dummy backend unavailable
|
|
13
|
-
DummyEmailBackend = None # type: ignore[assignment]
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def send(
|
|
19
|
-
subject: str,
|
|
20
|
-
message: str,
|
|
21
|
-
recipient_list: Sequence[str],
|
|
22
|
-
from_email: str | None = None,
|
|
23
|
-
*,
|
|
24
|
-
outbox=None,
|
|
25
|
-
attachments: Sequence[tuple[str, str, str]] | None = None,
|
|
26
|
-
content_subtype: str | None = None,
|
|
27
|
-
**kwargs,
|
|
28
|
-
):
|
|
29
|
-
"""Send an email using Django's email utilities.
|
|
30
|
-
|
|
31
|
-
If ``outbox`` is provided, its connection will be used when sending.
|
|
32
|
-
"""
|
|
33
|
-
sender = (
|
|
34
|
-
from_email or getattr(outbox, "from_email", None) or settings.DEFAULT_FROM_EMAIL
|
|
35
|
-
)
|
|
36
|
-
connection = outbox.get_connection() if outbox is not None else None
|
|
37
|
-
fail_silently = kwargs.pop("fail_silently", False)
|
|
38
|
-
email = EmailMessage(
|
|
39
|
-
subject=subject,
|
|
40
|
-
body=message,
|
|
41
|
-
from_email=sender,
|
|
42
|
-
to=list(recipient_list),
|
|
43
|
-
connection=connection,
|
|
44
|
-
**kwargs,
|
|
45
|
-
)
|
|
46
|
-
if attachments:
|
|
47
|
-
for attachment in attachments:
|
|
48
|
-
if not isinstance(attachment, (list, tuple)) or len(attachment) != 3:
|
|
49
|
-
raise ValueError(
|
|
50
|
-
"attachments must contain (name, content, mimetype) tuples"
|
|
51
|
-
)
|
|
52
|
-
email.attach(*attachment)
|
|
53
|
-
if content_subtype:
|
|
54
|
-
email.content_subtype = content_subtype
|
|
55
|
-
email.send(fail_silently=fail_silently)
|
|
56
|
-
return email
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def can_send_email() -> bool:
|
|
60
|
-
"""Return ``True`` when at least one outbound email path is configured."""
|
|
61
|
-
|
|
62
|
-
from nodes.models import EmailOutbox # imported lazily to avoid circular deps
|
|
63
|
-
|
|
64
|
-
has_outbox = (
|
|
65
|
-
EmailOutbox.objects.filter(is_enabled=True).exclude(host="").exists()
|
|
66
|
-
)
|
|
67
|
-
if has_outbox:
|
|
68
|
-
return True
|
|
69
|
-
|
|
70
|
-
backend_path = getattr(settings, "EMAIL_BACKEND", "")
|
|
71
|
-
if not backend_path:
|
|
72
|
-
return False
|
|
73
|
-
try:
|
|
74
|
-
backend_cls = import_string(backend_path)
|
|
75
|
-
except Exception: # pragma: no cover - misconfigured backend
|
|
76
|
-
logger.warning("Email backend %s could not be imported", backend_path)
|
|
77
|
-
return False
|
|
78
|
-
|
|
79
|
-
if DummyEmailBackend is None:
|
|
80
|
-
return True
|
|
81
|
-
try:
|
|
82
|
-
return not issubclass(backend_cls, DummyEmailBackend)
|
|
83
|
-
except TypeError: # pragma: no cover - backend not a class
|
|
84
|
-
logger.warning("Email backend %s is not a class", backend_path)
|
|
85
|
-
return False
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.mail import EmailMessage
|
|
6
|
+
from django.utils.module_loading import import_string
|
|
7
|
+
|
|
8
|
+
try: # pragma: no cover - import should always succeed but guard defensively
|
|
9
|
+
from django.core.mail.backends.dummy import (
|
|
10
|
+
EmailBackend as DummyEmailBackend,
|
|
11
|
+
)
|
|
12
|
+
except Exception: # pragma: no cover - fallback when dummy backend unavailable
|
|
13
|
+
DummyEmailBackend = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def send(
|
|
19
|
+
subject: str,
|
|
20
|
+
message: str,
|
|
21
|
+
recipient_list: Sequence[str],
|
|
22
|
+
from_email: str | None = None,
|
|
23
|
+
*,
|
|
24
|
+
outbox=None,
|
|
25
|
+
attachments: Sequence[tuple[str, str, str]] | None = None,
|
|
26
|
+
content_subtype: str | None = None,
|
|
27
|
+
**kwargs,
|
|
28
|
+
):
|
|
29
|
+
"""Send an email using Django's email utilities.
|
|
30
|
+
|
|
31
|
+
If ``outbox`` is provided, its connection will be used when sending.
|
|
32
|
+
"""
|
|
33
|
+
sender = (
|
|
34
|
+
from_email or getattr(outbox, "from_email", None) or settings.DEFAULT_FROM_EMAIL
|
|
35
|
+
)
|
|
36
|
+
connection = outbox.get_connection() if outbox is not None else None
|
|
37
|
+
fail_silently = kwargs.pop("fail_silently", False)
|
|
38
|
+
email = EmailMessage(
|
|
39
|
+
subject=subject,
|
|
40
|
+
body=message,
|
|
41
|
+
from_email=sender,
|
|
42
|
+
to=list(recipient_list),
|
|
43
|
+
connection=connection,
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
if attachments:
|
|
47
|
+
for attachment in attachments:
|
|
48
|
+
if not isinstance(attachment, (list, tuple)) or len(attachment) != 3:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"attachments must contain (name, content, mimetype) tuples"
|
|
51
|
+
)
|
|
52
|
+
email.attach(*attachment)
|
|
53
|
+
if content_subtype:
|
|
54
|
+
email.content_subtype = content_subtype
|
|
55
|
+
email.send(fail_silently=fail_silently)
|
|
56
|
+
return email
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def can_send_email() -> bool:
|
|
60
|
+
"""Return ``True`` when at least one outbound email path is configured."""
|
|
61
|
+
|
|
62
|
+
from nodes.models import EmailOutbox # imported lazily to avoid circular deps
|
|
63
|
+
|
|
64
|
+
has_outbox = (
|
|
65
|
+
EmailOutbox.objects.filter(is_enabled=True).exclude(host="").exists()
|
|
66
|
+
)
|
|
67
|
+
if has_outbox:
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
backend_path = getattr(settings, "EMAIL_BACKEND", "")
|
|
71
|
+
if not backend_path:
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
backend_cls = import_string(backend_path)
|
|
75
|
+
except Exception: # pragma: no cover - misconfigured backend
|
|
76
|
+
logger.warning("Email backend %s could not be imported", backend_path)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
if DummyEmailBackend is None:
|
|
80
|
+
return True
|
|
81
|
+
try:
|
|
82
|
+
return not issubclass(backend_cls, DummyEmailBackend)
|
|
83
|
+
except TypeError: # pragma: no cover - backend not a class
|
|
84
|
+
logger.warning("Email backend %s is not a class", backend_path)
|
|
85
|
+
return False
|