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.

Files changed (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
config/auth_app.py CHANGED
@@ -5,4 +5,3 @@ class AuthConfig(DjangoAuthConfig):
5
5
  """Use a shorter label for the auth section in the admin."""
6
6
 
7
7
  verbose_name = "AUTH"
8
-
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", "Virtual"}:
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
-
@@ -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(':')[0]
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 _validate_host_with_subnets(host, allowed_hosts):
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
- ip = ipaddress.ip_address(host)
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").read_text().splitlines()
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
- SECRET_KEY = "django-insecure-16%ed9hv^gg!jj5ff6d4w=t$50k^abkq75+vwl44%^+qyq!m#w"
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
- pass
102
- else:
103
- good_origin = "%s://%s" % (
104
- "https" if request.is_secure() else "http",
105
- good_host,
106
- )
107
- if request_origin == good_origin:
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
- try:
110
- origin_host = urlsplit(request_origin).hostname
111
- origin_ip = ipaddress.ip_address(origin_host)
112
- request_ip = ipaddress.ip_address(good_host.split(":")[0])
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 request_ip in network:
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
- "app",
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": 1,
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": 30},
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
- # Bluesky domain account (optional)
329
- BSKY_HANDLE = os.environ.get("BSKY_HANDLE")
330
- BSKY_APP_PASSWORD = os.environ.get("BSKY_APP_PASSWORD")
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 / "logs"
334
- LOG_DIR.mkdir(exist_ok=True)
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": "app.tasks.heartbeat",
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
-