arthexis 0.1.12__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.

Files changed (107) hide show
  1. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  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 -716
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2772
  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 +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -0
  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 +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2672
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -350
  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 +752 -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 +2095 -1511
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1382
  56. core/widgets.py +213 -51
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -898
  60. nodes/apps.py +87 -70
  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 +1737 -1416
  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 +3810 -2497
  71. nodes/urls.py +15 -13
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -451
  74. ocpp/admin.py +948 -804
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1342
  77. ocpp/evcs.py +844 -931
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -915
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -9
  82. ocpp/simulator.py +745 -724
  83. ocpp/status_display.py +26 -0
  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 -3485
  89. ocpp/transactions_io.py +189 -179
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1151
  92. pages/admin.py +708 -536
  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 -169
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2083
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1120
  104. arthexis-0.1.12.dist-info/RECORD +0 -102
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/apps.py CHANGED
@@ -1,70 +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 rev_short:
35
- body = f"{body} r{rev_short}" if body else f"r{rev_short}"
36
-
37
- def _worker() -> None: # pragma: no cover - background thread
38
- # Allow the LCD a moment to become ready and retry a few times
39
- for _ in range(5):
40
- try:
41
- from nodes.models import NetMessage
42
-
43
- NetMessage.broadcast(subject=f"{host}:{port}", body=body)
44
- break
45
- except Exception:
46
- time.sleep(1)
47
-
48
- threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
49
-
50
-
51
- def _trigger_startup_notification(**_: object) -> None:
52
- """Attempt to send the startup notification in the background."""
53
-
54
- try:
55
- connections["default"].ensure_connection()
56
- except OperationalError:
57
- logger.exception("Startup notification skipped: database unavailable")
58
- return
59
- _startup_notification()
60
-
61
-
62
- class NodesConfig(AppConfig):
63
- default_auto_field = "django.db.models.BigAutoField"
64
- name = "nodes"
65
- verbose_name = "4. Infrastructure"
66
-
67
- def ready(self): # pragma: no cover - exercised on app start
68
- from django.db.models.signals import post_migrate
69
-
70
- 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