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.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {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
- def _is_root() -> bool:
11
- if hasattr(os, "geteuid"):
12
- try:
13
- return os.geteuid() == 0
14
- except OSError: # pragma: no cover - defensive for unusual platforms
15
- return False
16
- return False
17
-
18
-
19
- def _state_home(base_home: Path) -> Path:
20
- state_home = os.environ.get("XDG_STATE_HOME")
21
- if state_home:
22
- return Path(state_home).expanduser()
23
- return base_home / ".local" / "state"
24
-
25
-
26
- def select_log_dir(base_dir: Path) -> Path:
27
- """Choose a writable log directory for the current process."""
28
-
29
- default = base_dir / "logs"
30
- env_override = os.environ.get("ARTHEXIS_LOG_DIR")
31
- is_root = _is_root()
32
- sudo_user = os.environ.get("SUDO_USER")
33
-
34
- candidates: list[Path] = []
35
- if env_override:
36
- candidates.append(Path(env_override).expanduser())
37
-
38
- if is_root:
39
- if not sudo_user or sudo_user == "root":
40
- candidates.append(default)
41
- candidates.append(Path("/var/log/arthexis"))
42
- candidates.append(Path("/tmp/arthexis/logs"))
43
- else:
44
- home = Path.home()
45
- state_home = _state_home(home)
46
- candidates.extend(
47
- [
48
- default,
49
- state_home / "arthexis" / "logs",
50
- home / ".arthexis" / "logs",
51
- Path("/tmp/arthexis/logs"),
52
- ]
53
- )
54
-
55
- seen: set[Path] = set()
56
- ordered_candidates: list[Path] = []
57
- for candidate in candidates:
58
- candidate = candidate.expanduser()
59
- if candidate not in seen:
60
- seen.add(candidate)
61
- ordered_candidates.append(candidate)
62
-
63
- attempted: list[Path] = []
64
- chosen: Path | None = None
65
- for candidate in ordered_candidates:
66
- attempted.append(candidate)
67
- try:
68
- candidate.mkdir(parents=True, exist_ok=True)
69
- except OSError:
70
- continue
71
- if os.access(candidate, os.W_OK | os.X_OK):
72
- chosen = candidate
73
- break
74
-
75
- if chosen is None:
76
- attempted_str = (
77
- ", ".join(str(path) for path in attempted) if attempted else "none"
78
- )
79
- raise RuntimeError(
80
- f"Unable to create a writable log directory. Tried: {attempted_str}"
81
- )
82
-
83
- if chosen != default:
84
- if (
85
- attempted
86
- and attempted[0] == default
87
- and not os.access(default, os.W_OK | os.X_OK)
88
- ):
89
- print(
90
- f"Log directory {default} is not writable; using {chosen}",
91
- file=sys.stderr,
92
- )
93
- elif is_root and sudo_user and sudo_user != "root" and not env_override:
94
- print(
95
- f"Running with elevated privileges; writing logs to {chosen}",
96
- file=sys.stderr,
97
- )
98
-
99
- os.environ["ARTHEXIS_LOG_DIR"] = str(chosen)
100
- return chosen
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