arthexis 0.1.8__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.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
config/auth_app.py
CHANGED
config/celery.py
CHANGED
|
@@ -9,7 +9,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
|
9
9
|
|
|
10
10
|
# When running on production-oriented nodes, avoid Celery debug mode.
|
|
11
11
|
NODE_ROLE = os.environ.get("NODE_ROLE", "")
|
|
12
|
-
if NODE_ROLE in {"Constellation", "Satellite"
|
|
12
|
+
if NODE_ROLE in {"Constellation", "Satellite"}:
|
|
13
13
|
for var in ["CELERY_TRACE_APP", "CELERY_DEBUG"]:
|
|
14
14
|
os.environ.pop(var, None)
|
|
15
15
|
os.environ.setdefault("CELERY_LOG_LEVEL", "INFO")
|
|
@@ -23,4 +23,3 @@ app.autodiscover_tasks()
|
|
|
23
23
|
def debug_task(self): # pragma: no cover - debug helper
|
|
24
24
|
"""A simple debug task."""
|
|
25
25
|
print(f"Request: {self.request!r}")
|
|
26
|
-
|
config/context_processors.py
CHANGED
|
@@ -13,7 +13,7 @@ def site_and_node(request: HttpRequest):
|
|
|
13
13
|
``badge_node`` is a ``Node`` instance or ``None`` if no match.
|
|
14
14
|
``badge_site_color`` and ``badge_node_color`` provide the configured colors.
|
|
15
15
|
"""
|
|
16
|
-
host = request.get_host().split(
|
|
16
|
+
host = request.get_host().split(":")[0]
|
|
17
17
|
site = Site.objects.filter(domain__iexact=host).first()
|
|
18
18
|
|
|
19
19
|
node = None
|
config/offline.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
|
2
2
|
import functools
|
|
3
3
|
import asyncio
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
class OfflineError(RuntimeError):
|
|
6
7
|
"""Raised when a network operation is attempted in offline mode."""
|
|
7
8
|
|
|
@@ -21,6 +22,7 @@ def requires_network(func):
|
|
|
21
22
|
"""
|
|
22
23
|
|
|
23
24
|
if asyncio.iscoroutinefunction(func):
|
|
25
|
+
|
|
24
26
|
@functools.wraps(func)
|
|
25
27
|
async def async_wrapper(*args, **kwargs):
|
|
26
28
|
if _is_offline():
|
config/settings.py
CHANGED
|
@@ -16,11 +16,16 @@ import os
|
|
|
16
16
|
import sys
|
|
17
17
|
import ipaddress
|
|
18
18
|
import socket
|
|
19
|
+
from core.log_paths import select_log_dir
|
|
19
20
|
from django.utils.translation import gettext_lazy as _
|
|
20
21
|
from celery.schedules import crontab
|
|
21
22
|
from django.http import request as http_request
|
|
23
|
+
from django.http.request import split_domain_port
|
|
22
24
|
from django.middleware.csrf import CsrfViewMiddleware
|
|
23
25
|
from django.core.exceptions import DisallowedHost
|
|
26
|
+
from django.contrib.sites import shortcuts as sites_shortcuts
|
|
27
|
+
from django.contrib.sites.requests import RequestSite
|
|
28
|
+
from django.core.management.utils import get_random_secret_key
|
|
24
29
|
from urllib.parse import urlsplit
|
|
25
30
|
import django.utils.encoding as encoding
|
|
26
31
|
|
|
@@ -30,13 +35,36 @@ if not hasattr(encoding, "force_text"): # pragma: no cover - Django>=5 compatib
|
|
|
30
35
|
encoding.force_text = force_str
|
|
31
36
|
|
|
32
37
|
|
|
38
|
+
|
|
33
39
|
_original_validate_host = http_request.validate_host
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
def
|
|
42
|
+
def _strip_ipv6_brackets(host: str) -> str:
|
|
43
|
+
if host.startswith("[") and host.endswith("]"):
|
|
44
|
+
return host[1:-1]
|
|
45
|
+
return host
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_ip_from_host(host: str):
|
|
49
|
+
"""Return an :mod:`ipaddress` object for ``host`` when possible."""
|
|
50
|
+
|
|
51
|
+
candidate = _strip_ipv6_brackets(host)
|
|
37
52
|
try:
|
|
38
|
-
|
|
53
|
+
return ipaddress.ip_address(candidate)
|
|
39
54
|
except ValueError:
|
|
55
|
+
domain, _port = split_domain_port(host)
|
|
56
|
+
if domain and domain != host:
|
|
57
|
+
candidate = _strip_ipv6_brackets(domain)
|
|
58
|
+
try:
|
|
59
|
+
return ipaddress.ip_address(candidate)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_host_with_subnets(host, allowed_hosts):
|
|
66
|
+
ip = _extract_ip_from_host(host)
|
|
67
|
+
if ip is None:
|
|
40
68
|
return _original_validate_host(host, allowed_hosts)
|
|
41
69
|
for pattern in allowed_hosts:
|
|
42
70
|
try:
|
|
@@ -57,7 +85,9 @@ ACRONYMS: list[str] = []
|
|
|
57
85
|
with contextlib.suppress(FileNotFoundError):
|
|
58
86
|
ACRONYMS = [
|
|
59
87
|
line.strip()
|
|
60
|
-
for line in (BASE_DIR / "config" / "data" / "ACRONYMS.txt")
|
|
88
|
+
for line in (BASE_DIR / "config" / "data" / "ACRONYMS.txt")
|
|
89
|
+
.read_text()
|
|
90
|
+
.splitlines()
|
|
61
91
|
if line.strip()
|
|
62
92
|
]
|
|
63
93
|
|
|
@@ -66,7 +96,29 @@ with contextlib.suppress(FileNotFoundError):
|
|
|
66
96
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
|
67
97
|
|
|
68
98
|
# SECURITY WARNING: keep the secret key used in production secret!
|
|
69
|
-
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_secret_key() -> str:
|
|
102
|
+
for env_var in ("DJANGO_SECRET_KEY", "SECRET_KEY"):
|
|
103
|
+
value = os.environ.get(env_var)
|
|
104
|
+
if value:
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
secret_file = BASE_DIR / "locks" / "django-secret.key"
|
|
108
|
+
with contextlib.suppress(OSError):
|
|
109
|
+
stored_key = secret_file.read_text(encoding="utf-8").strip()
|
|
110
|
+
if stored_key:
|
|
111
|
+
return stored_key
|
|
112
|
+
|
|
113
|
+
generated_key = get_random_secret_key()
|
|
114
|
+
with contextlib.suppress(OSError):
|
|
115
|
+
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
secret_file.write_text(generated_key, encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
return generated_key
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
SECRET_KEY = _load_secret_key()
|
|
70
122
|
|
|
71
123
|
# SECURITY WARNING: don't run with debug turned on in production!
|
|
72
124
|
|
|
@@ -89,36 +141,190 @@ ALLOWED_HOSTS = [
|
|
|
89
141
|
]
|
|
90
142
|
|
|
91
143
|
|
|
144
|
+
_DEFAULT_PORTS = {"http": "80", "https": "443"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_allowed_hosts() -> list[str]:
|
|
148
|
+
from django.conf import settings as django_settings
|
|
149
|
+
|
|
150
|
+
configured = getattr(django_settings, "ALLOWED_HOSTS", None)
|
|
151
|
+
if configured is None:
|
|
152
|
+
return ALLOWED_HOSTS
|
|
153
|
+
return list(configured)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _iter_local_hostnames(hostname: str, fqdn: str | None = None) -> list[str]:
|
|
157
|
+
"""Return unique hostname variants for the current machine."""
|
|
158
|
+
|
|
159
|
+
hostnames: list[str] = []
|
|
160
|
+
seen: set[str] = set()
|
|
161
|
+
|
|
162
|
+
def _append(candidate: str | None) -> None:
|
|
163
|
+
if not candidate:
|
|
164
|
+
return
|
|
165
|
+
normalized = candidate.strip()
|
|
166
|
+
if not normalized or normalized in seen:
|
|
167
|
+
return
|
|
168
|
+
hostnames.append(normalized)
|
|
169
|
+
seen.add(normalized)
|
|
170
|
+
|
|
171
|
+
_append(hostname)
|
|
172
|
+
_append(fqdn)
|
|
173
|
+
if hostname and "." not in hostname:
|
|
174
|
+
_append(f"{hostname}.local")
|
|
175
|
+
|
|
176
|
+
return hostnames
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_local_hostname = socket.gethostname().strip()
|
|
180
|
+
_local_fqdn = ""
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
_local_fqdn = socket.getfqdn().strip()
|
|
183
|
+
|
|
184
|
+
for host in _iter_local_hostnames(_local_hostname, _local_fqdn):
|
|
185
|
+
if host not in ALLOWED_HOSTS:
|
|
186
|
+
ALLOWED_HOSTS.append(host)
|
|
187
|
+
|
|
188
|
+
|
|
92
189
|
# Allow CSRF origin verification for hosts within allowed subnets.
|
|
93
190
|
_original_origin_verified = CsrfViewMiddleware._origin_verified
|
|
191
|
+
_original_check_referer = CsrfViewMiddleware._check_referer
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _host_is_allowed(host: str, allowed_hosts: list[str]) -> bool:
|
|
195
|
+
if http_request.validate_host(host, allowed_hosts):
|
|
196
|
+
return True
|
|
197
|
+
domain, _port = split_domain_port(host)
|
|
198
|
+
if domain and domain != host:
|
|
199
|
+
return http_request.validate_host(domain, allowed_hosts)
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_forwarded_header(header_value: str) -> list[dict[str, str]]:
|
|
204
|
+
entries: list[dict[str, str]] = []
|
|
205
|
+
if not header_value:
|
|
206
|
+
return entries
|
|
207
|
+
for forwarded_part in header_value.split(","):
|
|
208
|
+
entry: dict[str, str] = {}
|
|
209
|
+
for element in forwarded_part.split(";"):
|
|
210
|
+
if "=" not in element:
|
|
211
|
+
continue
|
|
212
|
+
key, value = element.split("=", 1)
|
|
213
|
+
entry[key.strip().lower()] = value.strip().strip('"')
|
|
214
|
+
if entry:
|
|
215
|
+
entries.append(entry)
|
|
216
|
+
return entries
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _get_request_scheme(request, forwarded_entry: dict[str, str] | None = None) -> str:
|
|
220
|
+
"""Return the scheme used by the client, honoring proxy headers."""
|
|
221
|
+
|
|
222
|
+
if forwarded_entry and forwarded_entry.get("proto", "").lower() in {"http", "https"}:
|
|
223
|
+
return forwarded_entry["proto"].lower()
|
|
224
|
+
|
|
225
|
+
if request.is_secure():
|
|
226
|
+
return "https"
|
|
227
|
+
|
|
228
|
+
forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO", "")
|
|
229
|
+
if forwarded_proto:
|
|
230
|
+
candidate = forwarded_proto.split(",")[0].strip().lower()
|
|
231
|
+
if candidate in {"http", "https"}:
|
|
232
|
+
return candidate
|
|
233
|
+
|
|
234
|
+
forwarded_header = request.META.get("HTTP_FORWARDED", "")
|
|
235
|
+
for forwarded_entry in _parse_forwarded_header(forwarded_header):
|
|
236
|
+
candidate = forwarded_entry.get("proto", "").lower()
|
|
237
|
+
if candidate in {"http", "https"}:
|
|
238
|
+
return candidate
|
|
239
|
+
|
|
240
|
+
return "http"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _normalize_origin_tuple(scheme: str | None, host: str) -> tuple[str, str, str | None] | None:
|
|
244
|
+
if not scheme or scheme.lower() not in {"http", "https"}:
|
|
245
|
+
return None
|
|
246
|
+
domain, port = split_domain_port(host)
|
|
247
|
+
normalized_host = _strip_ipv6_brackets(domain.strip().lower())
|
|
248
|
+
if not normalized_host:
|
|
249
|
+
return None
|
|
250
|
+
normalized_port = port.strip() if isinstance(port, str) else port
|
|
251
|
+
if not normalized_port:
|
|
252
|
+
normalized_port = _DEFAULT_PORTS.get(scheme.lower())
|
|
253
|
+
if normalized_port is not None:
|
|
254
|
+
normalized_port = str(normalized_port)
|
|
255
|
+
return scheme.lower(), normalized_host, normalized_port
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _normalized_request_origin(origin: str) -> tuple[str, str, str | None] | None:
|
|
259
|
+
parsed = urlsplit(origin)
|
|
260
|
+
if not parsed.scheme or not parsed.hostname:
|
|
261
|
+
return None
|
|
262
|
+
scheme = parsed.scheme.lower()
|
|
263
|
+
host = parsed.hostname.lower()
|
|
264
|
+
port = str(parsed.port) if parsed.port is not None else _DEFAULT_PORTS.get(scheme)
|
|
265
|
+
return scheme, host, port
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _candidate_origin_tuples(request, allowed_hosts: list[str]) -> list[tuple[str, str, str | None]]:
|
|
269
|
+
default_scheme = _get_request_scheme(request)
|
|
270
|
+
candidates: list[tuple[str, str, str | None]] = []
|
|
271
|
+
seen: set[tuple[str, str, str | None]] = set()
|
|
272
|
+
|
|
273
|
+
def _append_candidate(scheme: str | None, host: str) -> None:
|
|
274
|
+
if not scheme or not host:
|
|
275
|
+
return
|
|
276
|
+
normalized = _normalize_origin_tuple(scheme, host)
|
|
277
|
+
if normalized is None:
|
|
278
|
+
return
|
|
279
|
+
if not _host_is_allowed(host, allowed_hosts):
|
|
280
|
+
return
|
|
281
|
+
if normalized in seen:
|
|
282
|
+
return
|
|
283
|
+
candidates.append(normalized)
|
|
284
|
+
seen.add(normalized)
|
|
285
|
+
|
|
286
|
+
forwarded_header = request.META.get("HTTP_FORWARDED", "")
|
|
287
|
+
for forwarded_entry in _parse_forwarded_header(forwarded_header):
|
|
288
|
+
host = forwarded_entry.get("host", "").strip()
|
|
289
|
+
scheme = _get_request_scheme(request, forwarded_entry)
|
|
290
|
+
_append_candidate(scheme, host)
|
|
291
|
+
|
|
292
|
+
forwarded_host = request.META.get("HTTP_X_FORWARDED_HOST", "")
|
|
293
|
+
if forwarded_host:
|
|
294
|
+
host = forwarded_host.split(",")[0].strip()
|
|
295
|
+
_append_candidate(default_scheme, host)
|
|
94
296
|
|
|
95
|
-
|
|
96
|
-
def _origin_verified_with_subnets(self, request):
|
|
97
|
-
request_origin = request.META["HTTP_ORIGIN"]
|
|
98
297
|
try:
|
|
99
298
|
good_host = request.get_host()
|
|
100
299
|
except DisallowedHost:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
300
|
+
good_host = ""
|
|
301
|
+
if good_host:
|
|
302
|
+
_append_candidate(default_scheme, good_host)
|
|
303
|
+
|
|
304
|
+
return candidates
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _origin_verified_with_subnets(self, request):
|
|
308
|
+
request_origin = request.META["HTTP_ORIGIN"]
|
|
309
|
+
allowed_hosts = _get_allowed_hosts()
|
|
310
|
+
normalized_origin = _normalized_request_origin(request_origin)
|
|
311
|
+
if normalized_origin is None:
|
|
312
|
+
return _original_origin_verified(self, request)
|
|
313
|
+
|
|
314
|
+
origin_ip = _extract_ip_from_host(normalized_origin[1])
|
|
315
|
+
|
|
316
|
+
for candidate in _candidate_origin_tuples(request, allowed_hosts):
|
|
317
|
+
if candidate == normalized_origin:
|
|
108
318
|
return True
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
except ValueError:
|
|
114
|
-
pass
|
|
115
|
-
else:
|
|
116
|
-
for pattern in ALLOWED_HOSTS:
|
|
319
|
+
|
|
320
|
+
candidate_ip = _extract_ip_from_host(candidate[1])
|
|
321
|
+
if origin_ip and candidate_ip:
|
|
322
|
+
for pattern in allowed_hosts:
|
|
117
323
|
try:
|
|
118
324
|
network = ipaddress.ip_network(pattern)
|
|
119
325
|
except ValueError:
|
|
120
326
|
continue
|
|
121
|
-
if origin_ip in network and
|
|
327
|
+
if origin_ip in network and candidate_ip in network:
|
|
122
328
|
return True
|
|
123
329
|
return _original_origin_verified(self, request)
|
|
124
330
|
|
|
@@ -126,6 +332,49 @@ def _origin_verified_with_subnets(self, request):
|
|
|
126
332
|
CsrfViewMiddleware._origin_verified = _origin_verified_with_subnets
|
|
127
333
|
|
|
128
334
|
|
|
335
|
+
def _check_referer_with_forwarded(self, request):
|
|
336
|
+
referer = request.META.get("HTTP_REFERER")
|
|
337
|
+
if referer is None:
|
|
338
|
+
return _original_check_referer(self, request)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
parsed = urlsplit(referer)
|
|
342
|
+
except ValueError:
|
|
343
|
+
return _original_check_referer(self, request)
|
|
344
|
+
|
|
345
|
+
if "" in (parsed.scheme, parsed.netloc):
|
|
346
|
+
return _original_check_referer(self, request)
|
|
347
|
+
|
|
348
|
+
if parsed.scheme.lower() != "https":
|
|
349
|
+
return _original_check_referer(self, request)
|
|
350
|
+
|
|
351
|
+
normalized_referer = _normalize_origin_tuple(parsed.scheme.lower(), parsed.netloc)
|
|
352
|
+
if normalized_referer is None:
|
|
353
|
+
return _original_check_referer(self, request)
|
|
354
|
+
|
|
355
|
+
allowed_hosts = _get_allowed_hosts()
|
|
356
|
+
referer_ip = _extract_ip_from_host(normalized_referer[1])
|
|
357
|
+
|
|
358
|
+
for candidate in _candidate_origin_tuples(request, allowed_hosts):
|
|
359
|
+
if candidate == normalized_referer:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
candidate_ip = _extract_ip_from_host(candidate[1])
|
|
363
|
+
if referer_ip and candidate_ip:
|
|
364
|
+
for pattern in allowed_hosts:
|
|
365
|
+
try:
|
|
366
|
+
network = ipaddress.ip_network(pattern)
|
|
367
|
+
except ValueError:
|
|
368
|
+
continue
|
|
369
|
+
if referer_ip in network and candidate_ip in network:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
return _original_check_referer(self, request)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
CsrfViewMiddleware._check_referer = _check_referer_with_forwarded
|
|
376
|
+
|
|
377
|
+
|
|
129
378
|
# Application definition
|
|
130
379
|
|
|
131
380
|
LOCAL_APPS = [
|
|
@@ -134,12 +383,16 @@ LOCAL_APPS = [
|
|
|
134
383
|
"ocpp",
|
|
135
384
|
"awg",
|
|
136
385
|
"pages",
|
|
137
|
-
"
|
|
386
|
+
"man",
|
|
387
|
+
"teams",
|
|
138
388
|
]
|
|
139
389
|
|
|
140
390
|
INSTALLED_APPS = [
|
|
391
|
+
"whitenoise.runserver_nostatic",
|
|
141
392
|
"django.contrib.admin",
|
|
142
393
|
"django.contrib.admindocs",
|
|
394
|
+
"django_otp",
|
|
395
|
+
"django_otp.plugins.otp_totp",
|
|
143
396
|
"config.auth_app.AuthConfig",
|
|
144
397
|
"django.contrib.contenttypes",
|
|
145
398
|
"django.contrib.sessions",
|
|
@@ -149,7 +402,6 @@ INSTALLED_APPS = [
|
|
|
149
402
|
"django_object_actions",
|
|
150
403
|
"django.contrib.sites",
|
|
151
404
|
"channels",
|
|
152
|
-
"config.workgroup_app.WorkgroupConfig",
|
|
153
405
|
"config.horologia_app.HorologiaConfig",
|
|
154
406
|
] + LOCAL_APPS
|
|
155
407
|
|
|
@@ -163,15 +415,35 @@ if DEBUG:
|
|
|
163
415
|
|
|
164
416
|
SITE_ID = 1
|
|
165
417
|
|
|
418
|
+
_original_get_current_site = sites_shortcuts.get_current_site
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _get_current_site_with_request_fallback(request=None):
|
|
422
|
+
try:
|
|
423
|
+
return _original_get_current_site(request)
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
from django.contrib.sites.models import Site
|
|
426
|
+
|
|
427
|
+
if request is not None and isinstance(exc, Site.DoesNotExist):
|
|
428
|
+
return RequestSite(request)
|
|
429
|
+
raise
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
sites_shortcuts.get_current_site = _get_current_site_with_request_fallback
|
|
433
|
+
|
|
166
434
|
MIDDLEWARE = [
|
|
167
435
|
"django.middleware.security.SecurityMiddleware",
|
|
436
|
+
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
168
437
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
169
438
|
"config.middleware.ActiveAppMiddleware",
|
|
170
439
|
"django.middleware.locale.LocaleMiddleware",
|
|
171
440
|
"django.middleware.common.CommonMiddleware",
|
|
172
441
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
173
442
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
443
|
+
"django_otp.middleware.OTPMiddleware",
|
|
174
444
|
"core.middleware.AdminHistoryMiddleware",
|
|
445
|
+
"core.middleware.SigilContextMiddleware",
|
|
446
|
+
"pages.middleware.ViewHistoryMiddleware",
|
|
175
447
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
176
448
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
177
449
|
]
|
|
@@ -187,6 +459,9 @@ if DEBUG:
|
|
|
187
459
|
|
|
188
460
|
CSRF_FAILURE_VIEW = "pages.views.csrf_failure"
|
|
189
461
|
|
|
462
|
+
# Allow staff TODO pages to embed internal admin views inside iframes.
|
|
463
|
+
X_FRAME_OPTIONS = "SAMEORIGIN"
|
|
464
|
+
|
|
190
465
|
ROOT_URLCONF = "config.urls"
|
|
191
466
|
|
|
192
467
|
TEMPLATES = [
|
|
@@ -214,15 +489,43 @@ ASGI_APPLICATION = "config.asgi.application"
|
|
|
214
489
|
CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
|
|
215
490
|
|
|
216
491
|
|
|
492
|
+
# MCP sigil resolver configuration
|
|
493
|
+
def _env_int(name: str, default: int) -> int:
|
|
494
|
+
try:
|
|
495
|
+
return int(os.environ.get(name, default))
|
|
496
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
497
|
+
return default
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _split_env_list(name: str) -> list[str]:
|
|
501
|
+
raw = os.environ.get(name)
|
|
502
|
+
if not raw:
|
|
503
|
+
return []
|
|
504
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
MCP_SIGIL_SERVER = {
|
|
508
|
+
"host": os.environ.get("MCP_SIGIL_HOST", "127.0.0.1"),
|
|
509
|
+
"port": _env_int("MCP_SIGIL_PORT", 8800),
|
|
510
|
+
"api_keys": _split_env_list("MCP_SIGIL_API_KEYS"),
|
|
511
|
+
"required_scopes": ["sigils:read"],
|
|
512
|
+
"issuer_url": os.environ.get("MCP_SIGIL_ISSUER_URL"),
|
|
513
|
+
"resource_server_url": os.environ.get("MCP_SIGIL_RESOURCE_URL"),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
217
517
|
# Custom user model
|
|
218
518
|
AUTH_USER_MODEL = "core.User"
|
|
219
519
|
|
|
220
520
|
# Enable RFID authentication backend and restrict default admin login to localhost
|
|
221
521
|
AUTHENTICATION_BACKENDS = [
|
|
222
522
|
"core.backends.LocalhostAdminBackend",
|
|
523
|
+
"core.backends.TOTPBackend",
|
|
223
524
|
"core.backends.RFIDBackend",
|
|
224
525
|
]
|
|
225
526
|
|
|
527
|
+
# Issuer name used when generating otpauth URLs for authenticator apps.
|
|
528
|
+
OTP_TOTP_ISSUER = os.environ.get("OTP_TOTP_ISSUER", "Arthexis")
|
|
226
529
|
|
|
227
530
|
# Database
|
|
228
531
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
@@ -240,7 +543,7 @@ def _postgres_available() -> bool:
|
|
|
240
543
|
"password": os.environ.get("POSTGRES_PASSWORD", ""),
|
|
241
544
|
"host": os.environ.get("POSTGRES_HOST", "localhost"),
|
|
242
545
|
"port": os.environ.get("POSTGRES_PORT", "5432"),
|
|
243
|
-
"connect_timeout":
|
|
546
|
+
"connect_timeout": 10,
|
|
244
547
|
}
|
|
245
548
|
try:
|
|
246
549
|
with contextlib.closing(psycopg.connect(**params)):
|
|
@@ -259,6 +562,9 @@ if _postgres_available():
|
|
|
259
562
|
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
|
260
563
|
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
|
|
261
564
|
"OPTIONS": {"options": "-c timezone=UTC"},
|
|
565
|
+
"TEST": {
|
|
566
|
+
"NAME": f"{os.environ.get('POSTGRES_DB', 'postgres')}_test",
|
|
567
|
+
},
|
|
262
568
|
}
|
|
263
569
|
}
|
|
264
570
|
else:
|
|
@@ -266,7 +572,8 @@ else:
|
|
|
266
572
|
"default": {
|
|
267
573
|
"ENGINE": "django.db.backends.sqlite3",
|
|
268
574
|
"NAME": BASE_DIR / "db.sqlite3",
|
|
269
|
-
"OPTIONS": {"timeout":
|
|
575
|
+
"OPTIONS": {"timeout": 60},
|
|
576
|
+
"TEST": {"NAME": BASE_DIR / "test_db.sqlite3"},
|
|
270
577
|
}
|
|
271
578
|
}
|
|
272
579
|
|
|
@@ -296,8 +603,10 @@ AUTH_PASSWORD_VALIDATORS = [
|
|
|
296
603
|
LANGUAGE_CODE = "en-us"
|
|
297
604
|
|
|
298
605
|
LANGUAGES = [
|
|
299
|
-
("en", _("English")),
|
|
300
606
|
("es", _("Spanish")),
|
|
607
|
+
("en", _("English")),
|
|
608
|
+
("it", _("Italian")),
|
|
609
|
+
("de", _("German")),
|
|
301
610
|
]
|
|
302
611
|
|
|
303
612
|
LOCALE_PATHS = [BASE_DIR / "locale"]
|
|
@@ -313,10 +622,13 @@ USE_TZ = True
|
|
|
313
622
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
|
314
623
|
|
|
315
624
|
STATIC_URL = "/static/"
|
|
625
|
+
STATIC_ROOT = BASE_DIR / "static"
|
|
626
|
+
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
|
316
627
|
MEDIA_URL = "/media/"
|
|
317
628
|
MEDIA_ROOT = BASE_DIR / "media"
|
|
318
629
|
|
|
319
630
|
# Email settings
|
|
631
|
+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
320
632
|
DEFAULT_FROM_EMAIL = "arthexis@gmail.com"
|
|
321
633
|
SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
|
322
634
|
|
|
@@ -325,15 +637,15 @@ SERVER_EMAIL = DEFAULT_FROM_EMAIL
|
|
|
325
637
|
|
|
326
638
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
327
639
|
|
|
328
|
-
#
|
|
329
|
-
|
|
330
|
-
|
|
640
|
+
# GitHub issue reporting
|
|
641
|
+
GITHUB_ISSUE_REPORTING_ENABLED = True
|
|
642
|
+
GITHUB_ISSUE_REPORTING_COOLDOWN = 3600 # seconds
|
|
331
643
|
|
|
332
644
|
# Logging configuration
|
|
333
|
-
LOG_DIR = BASE_DIR
|
|
334
|
-
|
|
645
|
+
LOG_DIR = select_log_dir(BASE_DIR)
|
|
646
|
+
os.environ.setdefault("ARTHEXIS_LOG_DIR", str(LOG_DIR))
|
|
335
647
|
OLD_LOG_DIR = LOG_DIR / "old"
|
|
336
|
-
OLD_LOG_DIR.mkdir(exist_ok=True)
|
|
648
|
+
OLD_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
337
649
|
LOG_FILE_NAME = "tests.log" if "test" in sys.argv else f"{socket.gethostname()}.log"
|
|
338
650
|
|
|
339
651
|
LOGGING = {
|
|
@@ -368,8 +680,11 @@ CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
|
|
368
680
|
|
|
369
681
|
CELERY_BEAT_SCHEDULE = {
|
|
370
682
|
"heartbeat": {
|
|
371
|
-
"task": "
|
|
683
|
+
"task": "core.tasks.heartbeat",
|
|
372
684
|
"schedule": crontab(minute="*/5"),
|
|
373
|
-
}
|
|
685
|
+
},
|
|
686
|
+
"birthday_greetings": {
|
|
687
|
+
"task": "core.tasks.birthday_greetings",
|
|
688
|
+
"schedule": crontab(hour=9, minute=0),
|
|
689
|
+
},
|
|
374
690
|
}
|
|
375
|
-
|