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
config/settings.py CHANGED
@@ -1,716 +1,691 @@
1
- """
2
- Django settings for config project.
3
-
4
- Generated by 'django-admin startproject' using Django 5.2.4.
5
-
6
- For more information on this file, see
7
- https://docs.djangoproject.com/en/5.2/topics/settings/
8
-
9
- For the full list of settings and their values, see
10
- https://docs.djangoproject.com/en/5.2/ref/settings/
11
- """
12
-
13
- from pathlib import Path
14
- import contextlib
15
- import os
16
- import sys
17
- import ipaddress
18
- import socket
19
- from core.log_paths import select_log_dir
20
- from django.utils.translation import gettext_lazy as _
21
- from celery.schedules import crontab
22
- from django.http import request as http_request
23
- from django.http.request import split_domain_port
24
- from django.middleware.csrf import CsrfViewMiddleware
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
29
- from urllib.parse import urlsplit
30
- import django.utils.encoding as encoding
31
-
32
- if not hasattr(encoding, "force_text"): # pragma: no cover - Django>=5 compatibility
33
- from django.utils.encoding import force_str
34
-
35
- encoding.force_text = force_str
36
-
37
-
38
-
39
- _original_validate_host = http_request.validate_host
40
-
41
-
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)
52
- try:
53
- return ipaddress.ip_address(candidate)
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:
68
- return _original_validate_host(host, allowed_hosts)
69
- for pattern in allowed_hosts:
70
- try:
71
- network = ipaddress.ip_network(pattern)
72
- except ValueError:
73
- continue
74
- if ip in network:
75
- return True
76
- return _original_validate_host(host, allowed_hosts)
77
-
78
-
79
- http_request.validate_host = _validate_host_with_subnets
80
-
81
- # Build paths inside the project like this: BASE_DIR / 'subdir'.
82
- BASE_DIR = Path(__file__).resolve().parent.parent
83
-
84
- ACRONYMS: list[str] = []
85
- with contextlib.suppress(FileNotFoundError):
86
- ACRONYMS = [
87
- line.strip()
88
- for line in (BASE_DIR / "config" / "data" / "ACRONYMS.txt")
89
- .read_text()
90
- .splitlines()
91
- if line.strip()
92
- ]
93
-
94
-
95
- # Quick-start development settings - unsuitable for production
96
- # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
97
-
98
- # SECURITY WARNING: keep the secret key used in production secret!
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()
122
-
123
- # SECURITY WARNING: don't run with debug turned on in production!
124
-
125
- # Determine the current node role for role-specific settings while leaving
126
- # DEBUG control to the environment.
127
- NODE_ROLE = os.environ.get("NODE_ROLE")
128
- if NODE_ROLE is None:
129
- role_lock = BASE_DIR / "locks" / "role.lck"
130
- NODE_ROLE = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
131
-
132
- def _env_bool(name: str, default: bool) -> bool:
133
- value = os.environ.get(name)
134
- if value is None:
135
- return default
136
-
137
- normalized = value.strip().lower()
138
- if normalized in {"1", "true", "yes", "on"}:
139
- return True
140
- if normalized in {"0", "false", "no", "off"}:
141
- return False
142
- return default
143
-
144
-
145
- DEBUG = _env_bool("DEBUG", False)
146
-
147
- ALLOWED_HOSTS = [
148
- "localhost",
149
- "127.0.0.1",
150
- "testserver",
151
- "10.42.0.0/16",
152
- "192.168.0.0/16",
153
- "arthexis.com",
154
- "www.arthexis.com",
155
- ]
156
-
157
-
158
- _DEFAULT_PORTS = {"http": "80", "https": "443"}
159
-
160
-
161
- def _get_allowed_hosts() -> list[str]:
162
- from django.conf import settings as django_settings
163
-
164
- configured = getattr(django_settings, "ALLOWED_HOSTS", None)
165
- if configured is None:
166
- return ALLOWED_HOSTS
167
- return list(configured)
168
-
169
-
170
- def _iter_local_hostnames(hostname: str, fqdn: str | None = None) -> list[str]:
171
- """Return unique hostname variants for the current machine."""
172
-
173
- hostnames: list[str] = []
174
- seen: set[str] = set()
175
-
176
- def _append(candidate: str | None) -> None:
177
- if not candidate:
178
- return
179
- normalized = candidate.strip()
180
- if not normalized or normalized in seen:
181
- return
182
- hostnames.append(normalized)
183
- seen.add(normalized)
184
-
185
- _append(hostname)
186
- _append(fqdn)
187
- if hostname and "." not in hostname:
188
- _append(f"{hostname}.local")
189
-
190
- return hostnames
191
-
192
-
193
- _local_hostname = socket.gethostname().strip()
194
- _local_fqdn = ""
195
- with contextlib.suppress(Exception):
196
- _local_fqdn = socket.getfqdn().strip()
197
-
198
- for host in _iter_local_hostnames(_local_hostname, _local_fqdn):
199
- if host not in ALLOWED_HOSTS:
200
- ALLOWED_HOSTS.append(host)
201
-
202
-
203
- # Allow CSRF origin verification for hosts within allowed subnets.
204
- _original_origin_verified = CsrfViewMiddleware._origin_verified
205
- _original_check_referer = CsrfViewMiddleware._check_referer
206
-
207
-
208
- def _host_is_allowed(host: str, allowed_hosts: list[str]) -> bool:
209
- if http_request.validate_host(host, allowed_hosts):
210
- return True
211
- domain, _port = split_domain_port(host)
212
- if domain and domain != host:
213
- return http_request.validate_host(domain, allowed_hosts)
214
- return False
215
-
216
-
217
- def _parse_forwarded_header(header_value: str) -> list[dict[str, str]]:
218
- entries: list[dict[str, str]] = []
219
- if not header_value:
220
- return entries
221
- for forwarded_part in header_value.split(","):
222
- entry: dict[str, str] = {}
223
- for element in forwarded_part.split(";"):
224
- if "=" not in element:
225
- continue
226
- key, value = element.split("=", 1)
227
- entry[key.strip().lower()] = value.strip().strip('"')
228
- if entry:
229
- entries.append(entry)
230
- return entries
231
-
232
-
233
- def _get_request_scheme(request, forwarded_entry: dict[str, str] | None = None) -> str:
234
- """Return the scheme used by the client, honoring proxy headers."""
235
-
236
- if forwarded_entry and forwarded_entry.get("proto", "").lower() in {"http", "https"}:
237
- return forwarded_entry["proto"].lower()
238
-
239
- if request.is_secure():
240
- return "https"
241
-
242
- forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO", "")
243
- if forwarded_proto:
244
- candidate = forwarded_proto.split(",")[0].strip().lower()
245
- if candidate in {"http", "https"}:
246
- return candidate
247
-
248
- forwarded_header = request.META.get("HTTP_FORWARDED", "")
249
- for forwarded_entry in _parse_forwarded_header(forwarded_header):
250
- candidate = forwarded_entry.get("proto", "").lower()
251
- if candidate in {"http", "https"}:
252
- return candidate
253
-
254
- return "http"
255
-
256
-
257
- def _normalize_origin_tuple(scheme: str | None, host: str) -> tuple[str, str, str | None] | None:
258
- if not scheme or scheme.lower() not in {"http", "https"}:
259
- return None
260
- domain, port = split_domain_port(host)
261
- normalized_host = _strip_ipv6_brackets(domain.strip().lower())
262
- if not normalized_host:
263
- return None
264
- normalized_port = port.strip() if isinstance(port, str) else port
265
- if not normalized_port:
266
- normalized_port = _DEFAULT_PORTS.get(scheme.lower())
267
- if normalized_port is not None:
268
- normalized_port = str(normalized_port)
269
- return scheme.lower(), normalized_host, normalized_port
270
-
271
-
272
- def _normalized_request_origin(origin: str) -> tuple[str, str, str | None] | None:
273
- parsed = urlsplit(origin)
274
- if not parsed.scheme or not parsed.hostname:
275
- return None
276
- scheme = parsed.scheme.lower()
277
- host = parsed.hostname.lower()
278
- port = str(parsed.port) if parsed.port is not None else _DEFAULT_PORTS.get(scheme)
279
- return scheme, host, port
280
-
281
-
282
- def _candidate_origin_tuples(request, allowed_hosts: list[str]) -> list[tuple[str, str, str | None]]:
283
- default_scheme = _get_request_scheme(request)
284
- candidates: list[tuple[str, str, str | None]] = []
285
- seen: set[tuple[str, str, str | None]] = set()
286
-
287
- def _append_candidate(scheme: str | None, host: str) -> None:
288
- if not scheme or not host:
289
- return
290
- normalized = _normalize_origin_tuple(scheme, host)
291
- if normalized is None:
292
- return
293
- if not _host_is_allowed(host, allowed_hosts):
294
- return
295
- if normalized in seen:
296
- return
297
- candidates.append(normalized)
298
- seen.add(normalized)
299
-
300
- forwarded_header = request.META.get("HTTP_FORWARDED", "")
301
- for forwarded_entry in _parse_forwarded_header(forwarded_header):
302
- host = forwarded_entry.get("host", "").strip()
303
- scheme = _get_request_scheme(request, forwarded_entry)
304
- _append_candidate(scheme, host)
305
-
306
- forwarded_host = request.META.get("HTTP_X_FORWARDED_HOST", "")
307
- if forwarded_host:
308
- host = forwarded_host.split(",")[0].strip()
309
- _append_candidate(default_scheme, host)
310
-
311
- try:
312
- good_host = request.get_host()
313
- except DisallowedHost:
314
- good_host = ""
315
- if good_host:
316
- _append_candidate(default_scheme, good_host)
317
-
318
- return candidates
319
-
320
-
321
- def _origin_verified_with_subnets(self, request):
322
- request_origin = request.META["HTTP_ORIGIN"]
323
- allowed_hosts = _get_allowed_hosts()
324
- normalized_origin = _normalized_request_origin(request_origin)
325
- if normalized_origin is None:
326
- return _original_origin_verified(self, request)
327
-
328
- origin_ip = _extract_ip_from_host(normalized_origin[1])
329
-
330
- for candidate in _candidate_origin_tuples(request, allowed_hosts):
331
- if candidate == normalized_origin:
332
- return True
333
-
334
- candidate_ip = _extract_ip_from_host(candidate[1])
335
- if origin_ip and candidate_ip:
336
- for pattern in allowed_hosts:
337
- try:
338
- network = ipaddress.ip_network(pattern)
339
- except ValueError:
340
- continue
341
- if origin_ip in network and candidate_ip in network:
342
- return True
343
- return _original_origin_verified(self, request)
344
-
345
-
346
- CsrfViewMiddleware._origin_verified = _origin_verified_with_subnets
347
-
348
-
349
- def _check_referer_with_forwarded(self, request):
350
- referer = request.META.get("HTTP_REFERER")
351
- if referer is None:
352
- return _original_check_referer(self, request)
353
-
354
- try:
355
- parsed = urlsplit(referer)
356
- except ValueError:
357
- return _original_check_referer(self, request)
358
-
359
- if "" in (parsed.scheme, parsed.netloc):
360
- return _original_check_referer(self, request)
361
-
362
- if parsed.scheme.lower() != "https":
363
- return _original_check_referer(self, request)
364
-
365
- normalized_referer = _normalize_origin_tuple(parsed.scheme.lower(), parsed.netloc)
366
- if normalized_referer is None:
367
- return _original_check_referer(self, request)
368
-
369
- allowed_hosts = _get_allowed_hosts()
370
- referer_ip = _extract_ip_from_host(normalized_referer[1])
371
-
372
- for candidate in _candidate_origin_tuples(request, allowed_hosts):
373
- if candidate == normalized_referer:
374
- return
375
-
376
- candidate_ip = _extract_ip_from_host(candidate[1])
377
- if referer_ip and candidate_ip:
378
- for pattern in allowed_hosts:
379
- try:
380
- network = ipaddress.ip_network(pattern)
381
- except ValueError:
382
- continue
383
- if referer_ip in network and candidate_ip in network:
384
- return
385
-
386
- return _original_check_referer(self, request)
387
-
388
-
389
- CsrfViewMiddleware._check_referer = _check_referer_with_forwarded
390
-
391
-
392
- # Application definition
393
-
394
- LOCAL_APPS = [
395
- "nodes",
396
- "core",
397
- "ocpp",
398
- "awg",
399
- "pages",
400
- "teams",
401
- ]
402
-
403
- INSTALLED_APPS = [
404
- "whitenoise.runserver_nostatic",
405
- "django.contrib.admin",
406
- "django.contrib.admindocs",
407
- "django_otp",
408
- "django_otp.plugins.otp_totp",
409
- "config.auth_app.AuthConfig",
410
- "django.contrib.contenttypes",
411
- "django.contrib.sessions",
412
- "django.contrib.messages",
413
- "django.contrib.staticfiles",
414
- "import_export",
415
- "django_object_actions",
416
- "django.contrib.sites",
417
- "channels",
418
- "config.horologia_app.HorologiaConfig",
419
- ] + LOCAL_APPS
420
-
421
- if DEBUG:
422
- try:
423
- import debug_toolbar # type: ignore
424
- except ModuleNotFoundError: # pragma: no cover - optional dependency
425
- pass
426
- else:
427
- INSTALLED_APPS += ["debug_toolbar"]
428
-
429
- SITE_ID = 1
430
-
431
- _original_get_current_site = sites_shortcuts.get_current_site
432
-
433
-
434
- def _get_current_site_with_request_fallback(request=None):
435
- try:
436
- return _original_get_current_site(request)
437
- except Exception as exc:
438
- from django.contrib.sites.models import Site
439
-
440
- if request is not None and isinstance(exc, Site.DoesNotExist):
441
- return RequestSite(request)
442
- raise
443
-
444
-
445
- sites_shortcuts.get_current_site = _get_current_site_with_request_fallback
446
-
447
- MIDDLEWARE = [
448
- "django.middleware.security.SecurityMiddleware",
449
- "whitenoise.middleware.WhiteNoiseMiddleware",
450
- "django.contrib.sessions.middleware.SessionMiddleware",
451
- "config.middleware.ActiveAppMiddleware",
452
- "django.middleware.locale.LocaleMiddleware",
453
- "django.middleware.common.CommonMiddleware",
454
- "django.middleware.csrf.CsrfViewMiddleware",
455
- "django.contrib.auth.middleware.AuthenticationMiddleware",
456
- "django_otp.middleware.OTPMiddleware",
457
- "core.middleware.AdminHistoryMiddleware",
458
- "core.middleware.SigilContextMiddleware",
459
- "pages.middleware.ViewHistoryMiddleware",
460
- "django.contrib.messages.middleware.MessageMiddleware",
461
- "django.middleware.clickjacking.XFrameOptionsMiddleware",
462
- ]
463
-
464
- if DEBUG:
465
- try:
466
- import debug_toolbar # type: ignore
467
- except ModuleNotFoundError: # pragma: no cover - optional dependency
468
- pass
469
- else:
470
- MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
471
- INTERNAL_IPS = ["127.0.0.1", "localhost"]
472
-
473
- CSRF_FAILURE_VIEW = "pages.views.csrf_failure"
474
-
475
- # Allow staff TODO pages to embed internal admin views inside iframes.
476
- X_FRAME_OPTIONS = "SAMEORIGIN"
477
-
478
- ROOT_URLCONF = "config.urls"
479
-
480
- TEMPLATES = [
481
- {
482
- "BACKEND": "django.template.backends.django.DjangoTemplates",
483
- "DIRS": [BASE_DIR / "pages" / "templates"],
484
- "APP_DIRS": True,
485
- "OPTIONS": {
486
- "context_processors": [
487
- "django.template.context_processors.request",
488
- "django.contrib.auth.context_processors.auth",
489
- "django.template.context_processors.i18n",
490
- "django.contrib.messages.context_processors.messages",
491
- "pages.context_processors.nav_links",
492
- "config.context_processors.site_and_node",
493
- ],
494
- },
495
- },
496
- ]
497
-
498
- WSGI_APPLICATION = "config.wsgi.application"
499
- ASGI_APPLICATION = "config.asgi.application"
500
-
501
- # Channels configuration
502
- CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
503
-
504
-
505
- # MCP sigil resolver configuration
506
- def _env_int(name: str, default: int) -> int:
507
- try:
508
- return int(os.environ.get(name, default))
509
- except (TypeError, ValueError): # pragma: no cover - defensive
510
- return default
511
-
512
-
513
- def _split_env_list(name: str) -> list[str]:
514
- raw = os.environ.get(name)
515
- if not raw:
516
- return []
517
- return [item.strip() for item in raw.split(",") if item.strip()]
518
-
519
-
520
- MCP_SIGIL_SERVER = {
521
- "host": os.environ.get("MCP_SIGIL_HOST", "127.0.0.1"),
522
- "port": _env_int("MCP_SIGIL_PORT", 8800),
523
- "api_keys": _split_env_list("MCP_SIGIL_API_KEYS"),
524
- "required_scopes": ["sigils:read"],
525
- "issuer_url": os.environ.get("MCP_SIGIL_ISSUER_URL"),
526
- "resource_server_url": os.environ.get("MCP_SIGIL_RESOURCE_URL"),
527
- }
528
-
529
-
530
- # Custom user model
531
- AUTH_USER_MODEL = "core.User"
532
-
533
- # Enable RFID authentication backend and restrict default admin login to localhost
534
- AUTHENTICATION_BACKENDS = [
535
- "core.backends.TempPasswordBackend",
536
- "core.backends.LocalhostAdminBackend",
537
- "core.backends.TOTPBackend",
538
- "core.backends.RFIDBackend",
539
- ]
540
-
541
- # Issuer name used when generating otpauth URLs for authenticator apps.
542
- OTP_TOTP_ISSUER = os.environ.get("OTP_TOTP_ISSUER", "Arthexis")
543
-
544
- # Database
545
- # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
546
-
547
-
548
- def _postgres_available() -> bool:
549
- try:
550
- import psycopg
551
- except Exception:
552
- return False
553
-
554
- params = {
555
- "dbname": os.environ.get("POSTGRES_DB", "postgres"),
556
- "user": os.environ.get("POSTGRES_USER", "postgres"),
557
- "password": os.environ.get("POSTGRES_PASSWORD", ""),
558
- "host": os.environ.get("POSTGRES_HOST", "localhost"),
559
- "port": os.environ.get("POSTGRES_PORT", "5432"),
560
- "connect_timeout": 10,
561
- }
562
- try:
563
- with contextlib.closing(psycopg.connect(**params)):
564
- return True
565
- except psycopg.OperationalError:
566
- return False
567
-
568
-
569
- if _postgres_available():
570
- DATABASES = {
571
- "default": {
572
- "ENGINE": "django.db.backends.postgresql",
573
- "NAME": os.environ.get("POSTGRES_DB", "postgres"),
574
- "USER": os.environ.get("POSTGRES_USER", "postgres"),
575
- "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
576
- "HOST": os.environ.get("POSTGRES_HOST", "localhost"),
577
- "PORT": os.environ.get("POSTGRES_PORT", "5432"),
578
- "OPTIONS": {"options": "-c timezone=UTC"},
579
- "TEST": {
580
- "NAME": f"{os.environ.get('POSTGRES_DB', 'postgres')}_test",
581
- },
582
- }
583
- }
584
- else:
585
- DATABASES = {
586
- "default": {
587
- "ENGINE": "django.db.backends.sqlite3",
588
- "NAME": BASE_DIR / "db.sqlite3",
589
- "OPTIONS": {"timeout": 60},
590
- "TEST": {"NAME": BASE_DIR / "test_db.sqlite3"},
591
- }
592
- }
593
-
594
-
595
- # Password validation
596
- # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
597
-
598
- AUTH_PASSWORD_VALIDATORS = [
599
- {
600
- "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
601
- },
602
- {
603
- "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
604
- },
605
- {
606
- "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
607
- },
608
- {
609
- "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
610
- },
611
- ]
612
-
613
-
614
- # Internationalization
615
- # https://docs.djangoproject.com/en/5.2/topics/i18n/
616
-
617
- LANGUAGE_CODE = "en-us"
618
-
619
- LANGUAGES = [
620
- ("es", _("Spanish")),
621
- ("en", _("English")),
622
- ("it", _("Italian")),
623
- ("de", _("German")),
624
- ]
625
-
626
- LOCALE_PATHS = [BASE_DIR / "locale"]
627
-
628
- TIME_ZONE = "America/Monterrey"
629
-
630
- USE_I18N = True
631
-
632
- USE_TZ = True
633
-
634
-
635
- # Static files (CSS, JavaScript, Images)
636
- # https://docs.djangoproject.com/en/5.2/howto/static-files/
637
-
638
- STATIC_URL = "/static/"
639
- STATIC_ROOT = BASE_DIR / "static"
640
- STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
641
-
642
- # Allow development and freshly-updated environments to serve assets which have
643
- # not yet been collected into ``STATIC_ROOT``. Without this setting WhiteNoise
644
- # only looks for files inside ``STATIC_ROOT`` and dashboards like the public
645
- # traffic chart fail to load their JavaScript dependencies.
646
- WHITENOISE_USE_FINDERS = True
647
- WHITENOISE_AUTOREFRESH = DEBUG
648
- MEDIA_URL = "/media/"
649
- MEDIA_ROOT = BASE_DIR / "media"
650
-
651
- # Email settings
652
- EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
653
- DEFAULT_FROM_EMAIL = "arthexis@gmail.com"
654
- SERVER_EMAIL = DEFAULT_FROM_EMAIL
655
-
656
- # Default primary key field type
657
- # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
658
-
659
- DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
660
-
661
- # GitHub issue reporting
662
- GITHUB_ISSUE_REPORTING_ENABLED = True
663
- GITHUB_ISSUE_REPORTING_COOLDOWN = 3600 # seconds
664
-
665
- # Logging configuration
666
- LOG_DIR = select_log_dir(BASE_DIR)
667
- os.environ.setdefault("ARTHEXIS_LOG_DIR", str(LOG_DIR))
668
- OLD_LOG_DIR = LOG_DIR / "old"
669
- OLD_LOG_DIR.mkdir(parents=True, exist_ok=True)
670
- LOG_FILE_NAME = "tests.log" if "test" in sys.argv else f"{socket.gethostname()}.log"
671
-
672
- LOGGING = {
673
- "version": 1,
674
- "disable_existing_loggers": False,
675
- "formatters": {
676
- "standard": {
677
- "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
678
- }
679
- },
680
- "handlers": {
681
- "file": {
682
- "class": "config.logging.ActiveAppFileHandler",
683
- "filename": str(LOG_DIR / LOG_FILE_NAME),
684
- "when": "midnight",
685
- "backupCount": 7,
686
- "encoding": "utf-8",
687
- "formatter": "standard",
688
- },
689
- "console": {
690
- "class": "logging.StreamHandler",
691
- "level": "ERROR",
692
- "formatter": "standard",
693
- },
694
- },
695
- "root": {
696
- "handlers": ["file", "console"],
697
- "level": "DEBUG",
698
- },
699
- }
700
-
701
-
702
- # Celery configuration
703
- CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "memory://")
704
- CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "cache+memory://")
705
- CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
706
-
707
- CELERY_BEAT_SCHEDULE = {
708
- "heartbeat": {
709
- "task": "core.tasks.heartbeat",
710
- "schedule": crontab(minute="*/5"),
711
- },
712
- "birthday_greetings": {
713
- "task": "core.tasks.birthday_greetings",
714
- "schedule": crontab(hour=9, minute=0),
715
- },
716
- }
1
+ """
2
+ Django settings for config project.
3
+
4
+ Generated by 'django-admin startproject' using Django 5.2.4.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/5.2/ref/settings/
11
+ """
12
+
13
+ from pathlib import Path
14
+ import contextlib
15
+ import os
16
+ import sys
17
+ import ipaddress
18
+ import socket
19
+ from core.log_paths import select_log_dir
20
+ from django.utils.translation import gettext_lazy as _
21
+ from celery.schedules import crontab
22
+ from django.http import request as http_request
23
+ from django.http.request import split_domain_port
24
+ from django.middleware.csrf import CsrfViewMiddleware
25
+ from django.core.exceptions import DisallowedHost, ImproperlyConfigured
26
+ from django.contrib.sites import shortcuts as sites_shortcuts
27
+ from django.contrib.sites.requests import RequestSite
28
+ from urllib.parse import urlsplit
29
+ import django.utils.encoding as encoding
30
+
31
+ from config.settings_helpers import (
32
+ extract_ip_from_host,
33
+ install_validate_host_with_subnets,
34
+ load_secret_key,
35
+ strip_ipv6_brackets,
36
+ )
37
+
38
+ if not hasattr(encoding, "force_text"): # pragma: no cover - Django>=5 compatibility
39
+ from django.utils.encoding import force_str
40
+
41
+ encoding.force_text = force_str
42
+ install_validate_host_with_subnets()
43
+
44
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
45
+ BASE_DIR = Path(__file__).resolve().parent.parent
46
+
47
+ ACRONYMS: list[str] = []
48
+ with contextlib.suppress(FileNotFoundError):
49
+ ACRONYMS = [
50
+ line.strip()
51
+ for line in (BASE_DIR / "config" / "data" / "ACRONYMS.txt")
52
+ .read_text()
53
+ .splitlines()
54
+ if line.strip()
55
+ ]
56
+
57
+
58
+ # Quick-start development settings - unsuitable for production
59
+ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
60
+
61
+ # SECURITY WARNING: keep the secret key used in production secret!
62
+ SECRET_KEY = load_secret_key(BASE_DIR)
63
+
64
+ # SECURITY WARNING: don't run with debug turned on in production!
65
+
66
+ # Determine the current node role for role-specific settings while leaving
67
+ # DEBUG control to the environment.
68
+ NODE_ROLE = os.environ.get("NODE_ROLE")
69
+ if NODE_ROLE is None:
70
+ role_lock = BASE_DIR / "locks" / "role.lck"
71
+ NODE_ROLE = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
72
+
73
+ def _env_bool(name: str, default: bool) -> bool:
74
+ value = os.environ.get(name)
75
+ if value is None:
76
+ return default
77
+
78
+ normalized = value.strip().lower()
79
+ if normalized in {"1", "true", "yes", "on"}:
80
+ return True
81
+ if normalized in {"0", "false", "no", "off"}:
82
+ return False
83
+ return default
84
+
85
+
86
+ DEBUG = _env_bool("DEBUG", False)
87
+
88
+ ALLOWED_HOSTS = [
89
+ "localhost",
90
+ "127.0.0.1",
91
+ "testserver",
92
+ "10.42.0.0/16",
93
+ "192.168.0.0/16",
94
+ "arthexis.com",
95
+ "www.arthexis.com",
96
+ ]
97
+
98
+
99
+ _DEFAULT_PORTS = {"http": "80", "https": "443"}
100
+
101
+
102
+ def _get_allowed_hosts() -> list[str]:
103
+ from django.conf import settings as django_settings
104
+
105
+ configured = getattr(django_settings, "ALLOWED_HOSTS", None)
106
+ if configured is None:
107
+ return ALLOWED_HOSTS
108
+ return list(configured)
109
+
110
+
111
+ def _iter_local_hostnames(hostname: str, fqdn: str | None = None) -> list[str]:
112
+ """Return unique hostname variants for the current machine."""
113
+
114
+ hostnames: list[str] = []
115
+ seen: set[str] = set()
116
+
117
+ def _append(candidate: str | None) -> None:
118
+ if not candidate:
119
+ return
120
+ normalized = candidate.strip()
121
+ if not normalized or normalized in seen:
122
+ return
123
+ hostnames.append(normalized)
124
+ seen.add(normalized)
125
+
126
+ _append(hostname)
127
+ _append(fqdn)
128
+ if hostname and "." not in hostname:
129
+ _append(f"{hostname}.local")
130
+
131
+ return hostnames
132
+
133
+
134
+ _local_hostname = socket.gethostname().strip()
135
+ _local_fqdn = ""
136
+ with contextlib.suppress(Exception):
137
+ _local_fqdn = socket.getfqdn().strip()
138
+
139
+ for host in _iter_local_hostnames(_local_hostname, _local_fqdn):
140
+ if host not in ALLOWED_HOSTS:
141
+ ALLOWED_HOSTS.append(host)
142
+
143
+
144
+ # Allow CSRF origin verification for hosts within allowed subnets.
145
+ _original_origin_verified = CsrfViewMiddleware._origin_verified
146
+ _original_check_referer = CsrfViewMiddleware._check_referer
147
+
148
+
149
+ def _host_is_allowed(host: str, allowed_hosts: list[str]) -> bool:
150
+ if http_request.validate_host(host, allowed_hosts):
151
+ return True
152
+ domain, _port = split_domain_port(host)
153
+ if domain and domain != host:
154
+ return http_request.validate_host(domain, allowed_hosts)
155
+ return False
156
+
157
+
158
+ def _parse_forwarded_header(header_value: str) -> list[dict[str, str]]:
159
+ entries: list[dict[str, str]] = []
160
+ if not header_value:
161
+ return entries
162
+ for forwarded_part in header_value.split(","):
163
+ entry: dict[str, str] = {}
164
+ for element in forwarded_part.split(";"):
165
+ if "=" not in element:
166
+ continue
167
+ key, value = element.split("=", 1)
168
+ entry[key.strip().lower()] = value.strip().strip('"')
169
+ if entry:
170
+ entries.append(entry)
171
+ return entries
172
+
173
+
174
+ def _get_request_scheme(request, forwarded_entry: dict[str, str] | None = None) -> str:
175
+ """Return the scheme used by the client, honoring proxy headers."""
176
+
177
+ if forwarded_entry and forwarded_entry.get("proto", "").lower() in {"http", "https"}:
178
+ return forwarded_entry["proto"].lower()
179
+
180
+ if request.is_secure():
181
+ return "https"
182
+
183
+ forwarded_proto = request.META.get("HTTP_X_FORWARDED_PROTO", "")
184
+ if forwarded_proto:
185
+ candidate = forwarded_proto.split(",")[0].strip().lower()
186
+ if candidate in {"http", "https"}:
187
+ return candidate
188
+
189
+ forwarded_header = request.META.get("HTTP_FORWARDED", "")
190
+ for forwarded_entry in _parse_forwarded_header(forwarded_header):
191
+ candidate = forwarded_entry.get("proto", "").lower()
192
+ if candidate in {"http", "https"}:
193
+ return candidate
194
+
195
+ return "http"
196
+
197
+
198
+ def _normalize_origin_tuple(scheme: str | None, host: str) -> tuple[str, str, str | None] | None:
199
+ if not scheme or scheme.lower() not in {"http", "https"}:
200
+ return None
201
+ domain, port = split_domain_port(host)
202
+ normalized_host = strip_ipv6_brackets(domain.strip().lower())
203
+ if not normalized_host:
204
+ return None
205
+ normalized_port = port.strip() if isinstance(port, str) else port
206
+ if not normalized_port:
207
+ normalized_port = _DEFAULT_PORTS.get(scheme.lower())
208
+ if normalized_port is not None:
209
+ normalized_port = str(normalized_port)
210
+ return scheme.lower(), normalized_host, normalized_port
211
+
212
+
213
+ def _normalized_request_origin(origin: str) -> tuple[str, str, str | None] | None:
214
+ parsed = urlsplit(origin)
215
+ if not parsed.scheme or not parsed.hostname:
216
+ return None
217
+ scheme = parsed.scheme.lower()
218
+ host = parsed.hostname.lower()
219
+ port = str(parsed.port) if parsed.port is not None else _DEFAULT_PORTS.get(scheme)
220
+ return scheme, host, port
221
+
222
+
223
+ def _candidate_origin_tuples(request, allowed_hosts: list[str]) -> list[tuple[str, str, str | None]]:
224
+ default_scheme = _get_request_scheme(request)
225
+ candidates: list[tuple[str, str, str | None]] = []
226
+ seen: set[tuple[str, str, str | None]] = set()
227
+
228
+ def _append_candidate(scheme: str | None, host: str) -> None:
229
+ if not scheme or not host:
230
+ return
231
+ normalized = _normalize_origin_tuple(scheme, host)
232
+ if normalized is None:
233
+ return
234
+ if not _host_is_allowed(host, allowed_hosts):
235
+ return
236
+ if normalized in seen:
237
+ return
238
+ candidates.append(normalized)
239
+ seen.add(normalized)
240
+
241
+ forwarded_header = request.META.get("HTTP_FORWARDED", "")
242
+ for forwarded_entry in _parse_forwarded_header(forwarded_header):
243
+ host = forwarded_entry.get("host", "").strip()
244
+ scheme = _get_request_scheme(request, forwarded_entry)
245
+ _append_candidate(scheme, host)
246
+
247
+ forwarded_host = request.META.get("HTTP_X_FORWARDED_HOST", "")
248
+ if forwarded_host:
249
+ host = forwarded_host.split(",")[0].strip()
250
+ _append_candidate(default_scheme, host)
251
+
252
+ try:
253
+ good_host = request.get_host()
254
+ except DisallowedHost:
255
+ good_host = ""
256
+ if good_host:
257
+ _append_candidate(default_scheme, good_host)
258
+
259
+ return candidates
260
+
261
+
262
+ def _origin_verified_with_subnets(self, request):
263
+ request_origin = request.META["HTTP_ORIGIN"]
264
+ allowed_hosts = _get_allowed_hosts()
265
+ normalized_origin = _normalized_request_origin(request_origin)
266
+ if normalized_origin is None:
267
+ return _original_origin_verified(self, request)
268
+
269
+ origin_ip = extract_ip_from_host(normalized_origin[1])
270
+
271
+ for candidate in _candidate_origin_tuples(request, allowed_hosts):
272
+ if candidate == normalized_origin:
273
+ return True
274
+
275
+ candidate_ip = extract_ip_from_host(candidate[1])
276
+ if origin_ip and candidate_ip:
277
+ for pattern in allowed_hosts:
278
+ try:
279
+ network = ipaddress.ip_network(pattern)
280
+ except ValueError:
281
+ continue
282
+ if origin_ip in network and candidate_ip in network:
283
+ return True
284
+ return _original_origin_verified(self, request)
285
+
286
+
287
+ CsrfViewMiddleware._origin_verified = _origin_verified_with_subnets
288
+
289
+
290
+ def _check_referer_with_forwarded(self, request):
291
+ referer = request.META.get("HTTP_REFERER")
292
+ if referer is None:
293
+ return _original_check_referer(self, request)
294
+
295
+ try:
296
+ parsed = urlsplit(referer)
297
+ except ValueError:
298
+ return _original_check_referer(self, request)
299
+
300
+ if "" in (parsed.scheme, parsed.netloc):
301
+ return _original_check_referer(self, request)
302
+
303
+ if parsed.scheme.lower() != "https":
304
+ return _original_check_referer(self, request)
305
+
306
+ normalized_referer = _normalize_origin_tuple(parsed.scheme.lower(), parsed.netloc)
307
+ if normalized_referer is None:
308
+ return _original_check_referer(self, request)
309
+
310
+ allowed_hosts = _get_allowed_hosts()
311
+ referer_ip = extract_ip_from_host(normalized_referer[1])
312
+
313
+ for candidate in _candidate_origin_tuples(request, allowed_hosts):
314
+ if candidate == normalized_referer:
315
+ return
316
+
317
+ candidate_ip = extract_ip_from_host(candidate[1])
318
+ if referer_ip and candidate_ip:
319
+ for pattern in allowed_hosts:
320
+ try:
321
+ network = ipaddress.ip_network(pattern)
322
+ except ValueError:
323
+ continue
324
+ if referer_ip in network and candidate_ip in network:
325
+ return
326
+
327
+ return _original_check_referer(self, request)
328
+
329
+
330
+ CsrfViewMiddleware._check_referer = _check_referer_with_forwarded
331
+
332
+
333
+ # Application definition
334
+
335
+ LOCAL_APPS = [
336
+ "nodes",
337
+ "core",
338
+ "ocpp",
339
+ "awg",
340
+ "pages",
341
+ "teams",
342
+ ]
343
+
344
+ INSTALLED_APPS = [
345
+ "whitenoise.runserver_nostatic",
346
+ "django.contrib.admin",
347
+ "django.contrib.admindocs",
348
+ "django_otp",
349
+ "django_otp.plugins.otp_totp",
350
+ "config.auth_app.AuthConfig",
351
+ "django.contrib.contenttypes",
352
+ "django.contrib.sessions",
353
+ "django.contrib.messages",
354
+ "django.contrib.staticfiles",
355
+ "import_export",
356
+ "django_object_actions",
357
+ "django.contrib.sites",
358
+ "channels",
359
+ "config.horologia_app.HorologiaConfig",
360
+ ] + LOCAL_APPS
361
+
362
+ if DEBUG:
363
+ try:
364
+ import debug_toolbar # type: ignore
365
+ except ModuleNotFoundError: # pragma: no cover - optional dependency
366
+ pass
367
+ else:
368
+ INSTALLED_APPS += ["debug_toolbar"]
369
+
370
+ SITE_ID = 1
371
+
372
+ _original_get_current_site = sites_shortcuts.get_current_site
373
+
374
+
375
+ def _get_current_site_with_request_fallback(request=None):
376
+ try:
377
+ return _original_get_current_site(request)
378
+ except Exception as exc:
379
+ from django.contrib.sites.models import Site
380
+
381
+ if request is not None and isinstance(exc, Site.DoesNotExist):
382
+ return RequestSite(request)
383
+ raise
384
+
385
+
386
+ sites_shortcuts.get_current_site = _get_current_site_with_request_fallback
387
+
388
+ MIDDLEWARE = [
389
+ "django.middleware.security.SecurityMiddleware",
390
+ "whitenoise.middleware.WhiteNoiseMiddleware",
391
+ "django.contrib.sessions.middleware.SessionMiddleware",
392
+ "config.middleware.ActiveAppMiddleware",
393
+ "django.middleware.locale.LocaleMiddleware",
394
+ "django.middleware.common.CommonMiddleware",
395
+ "django.middleware.csrf.CsrfViewMiddleware",
396
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
397
+ "django_otp.middleware.OTPMiddleware",
398
+ "core.middleware.AdminHistoryMiddleware",
399
+ "core.middleware.SigilContextMiddleware",
400
+ "pages.middleware.ViewHistoryMiddleware",
401
+ "django.contrib.messages.middleware.MessageMiddleware",
402
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
403
+ ]
404
+
405
+ if DEBUG:
406
+ try:
407
+ import debug_toolbar # type: ignore
408
+ except ModuleNotFoundError: # pragma: no cover - optional dependency
409
+ pass
410
+ else:
411
+ MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
412
+ INTERNAL_IPS = ["127.0.0.1", "localhost"]
413
+
414
+ CSRF_FAILURE_VIEW = "pages.views.csrf_failure"
415
+
416
+ # Allow staff TODO pages to embed internal admin views inside iframes.
417
+ X_FRAME_OPTIONS = "SAMEORIGIN"
418
+
419
+ ROOT_URLCONF = "config.urls"
420
+
421
+ TEMPLATES = [
422
+ {
423
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
424
+ "DIRS": [BASE_DIR / "pages" / "templates"],
425
+ "APP_DIRS": True,
426
+ "OPTIONS": {
427
+ "context_processors": [
428
+ "django.template.context_processors.request",
429
+ "django.contrib.auth.context_processors.auth",
430
+ "django.template.context_processors.i18n",
431
+ "django.contrib.messages.context_processors.messages",
432
+ "pages.context_processors.nav_links",
433
+ "config.context_processors.site_and_node",
434
+ ],
435
+ },
436
+ },
437
+ ]
438
+
439
+ WSGI_APPLICATION = "config.wsgi.application"
440
+ ASGI_APPLICATION = "config.asgi.application"
441
+
442
+ # Channels configuration
443
+ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
444
+
445
+
446
+ # MCP sigil resolver configuration
447
+ def _env_int(name: str, default: int) -> int:
448
+ try:
449
+ return int(os.environ.get(name, default))
450
+ except (TypeError, ValueError): # pragma: no cover - defensive
451
+ return default
452
+
453
+
454
+ def _split_env_list(name: str) -> list[str]:
455
+ raw = os.environ.get(name)
456
+ if not raw:
457
+ return []
458
+ return [item.strip() for item in raw.split(",") if item.strip()]
459
+
460
+
461
+ MCP_SIGIL_SERVER = {
462
+ "host": os.environ.get("MCP_SIGIL_HOST", "127.0.0.1"),
463
+ "port": _env_int("MCP_SIGIL_PORT", 8800),
464
+ "api_keys": _split_env_list("MCP_SIGIL_API_KEYS"),
465
+ "required_scopes": ["sigils:read"],
466
+ "issuer_url": os.environ.get("MCP_SIGIL_ISSUER_URL"),
467
+ "resource_server_url": os.environ.get("MCP_SIGIL_RESOURCE_URL"),
468
+ "mount_path": os.environ.get("MCP_SIGIL_MOUNT_PATH", "/mcp"),
469
+ }
470
+
471
+
472
+ # Custom user model
473
+ AUTH_USER_MODEL = "core.User"
474
+
475
+ # Enable RFID authentication backend and restrict default admin login to localhost
476
+ AUTHENTICATION_BACKENDS = [
477
+ "core.backends.TempPasswordBackend",
478
+ "core.backends.LocalhostAdminBackend",
479
+ "core.backends.TOTPBackend",
480
+ "core.backends.RFIDBackend",
481
+ ]
482
+
483
+ # Issuer name used when generating otpauth URLs for authenticator apps.
484
+ OTP_TOTP_ISSUER = os.environ.get("OTP_TOTP_ISSUER", "Arthexis")
485
+
486
+ # Database
487
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
488
+
489
+ FORCED_DB_BACKEND = os.environ.get("ARTHEXIS_FORCE_DB_BACKEND", "").strip().lower()
490
+ if FORCED_DB_BACKEND and FORCED_DB_BACKEND not in {"sqlite", "postgres"}:
491
+ raise ImproperlyConfigured(
492
+ "ARTHEXIS_FORCE_DB_BACKEND must be 'sqlite' or 'postgres' when defined."
493
+ )
494
+
495
+
496
+ def _postgres_available() -> bool:
497
+ if FORCED_DB_BACKEND == "sqlite":
498
+ return False
499
+ try:
500
+ import psycopg
501
+ except Exception:
502
+ return False
503
+
504
+ params = {
505
+ "dbname": os.environ.get("POSTGRES_DB", "postgres"),
506
+ "user": os.environ.get("POSTGRES_USER", "postgres"),
507
+ "password": os.environ.get("POSTGRES_PASSWORD", ""),
508
+ "host": os.environ.get("POSTGRES_HOST", "localhost"),
509
+ "port": os.environ.get("POSTGRES_PORT", "5432"),
510
+ "connect_timeout": 10,
511
+ }
512
+ try:
513
+ with contextlib.closing(psycopg.connect(**params)):
514
+ return True
515
+ except psycopg.OperationalError:
516
+ return False
517
+
518
+
519
+ if FORCED_DB_BACKEND == "postgres":
520
+ _use_postgres = True
521
+ elif FORCED_DB_BACKEND == "sqlite":
522
+ _use_postgres = False
523
+ else:
524
+ _use_postgres = _postgres_available()
525
+
526
+
527
+ if _use_postgres:
528
+ DATABASES = {
529
+ "default": {
530
+ "ENGINE": "django.db.backends.postgresql",
531
+ "NAME": os.environ.get("POSTGRES_DB", "postgres"),
532
+ "USER": os.environ.get("POSTGRES_USER", "postgres"),
533
+ "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
534
+ "HOST": os.environ.get("POSTGRES_HOST", "localhost"),
535
+ "PORT": os.environ.get("POSTGRES_PORT", "5432"),
536
+ "OPTIONS": {"options": "-c timezone=UTC"},
537
+ "TEST": {
538
+ "NAME": f"{os.environ.get('POSTGRES_DB', 'postgres')}_test",
539
+ },
540
+ }
541
+ }
542
+ else:
543
+ _sqlite_override = os.environ.get("ARTHEXIS_SQLITE_PATH")
544
+ if _sqlite_override:
545
+ SQLITE_DB_PATH = Path(_sqlite_override)
546
+ else:
547
+ SQLITE_DB_PATH = BASE_DIR / "db.sqlite3"
548
+
549
+ DATABASES = {
550
+ "default": {
551
+ "ENGINE": "django.db.backends.sqlite3",
552
+ "NAME": SQLITE_DB_PATH,
553
+ "OPTIONS": {"timeout": 60},
554
+ "TEST": {"NAME": BASE_DIR / "test_db.sqlite3"},
555
+ }
556
+ }
557
+
558
+
559
+ # Password validation
560
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
561
+
562
+ AUTH_PASSWORD_VALIDATORS = [
563
+ {
564
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
565
+ },
566
+ {
567
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
568
+ },
569
+ {
570
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
571
+ },
572
+ {
573
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
574
+ },
575
+ ]
576
+
577
+
578
+ # Internationalization
579
+ # https://docs.djangoproject.com/en/5.2/topics/i18n/
580
+
581
+ LANGUAGE_CODE = "en-us"
582
+
583
+ LANGUAGES = [
584
+ ("es", _("Spanish")),
585
+ ("en", _("English")),
586
+ ("it", _("Italian")),
587
+ ("de", _("German")),
588
+ ]
589
+
590
+ LOCALE_PATHS = [BASE_DIR / "locale"]
591
+
592
+ FORMAT_MODULE_PATH = ["config.formats"]
593
+
594
+ TIME_ZONE = "America/Monterrey"
595
+
596
+ USE_I18N = True
597
+
598
+ USE_TZ = True
599
+
600
+
601
+ # Static files (CSS, JavaScript, Images)
602
+ # https://docs.djangoproject.com/en/5.2/howto/static-files/
603
+
604
+ STATIC_URL = "/static/"
605
+ STATIC_ROOT = BASE_DIR / "static"
606
+ STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
607
+
608
+ # Allow development and freshly-updated environments to serve assets which have
609
+ # not yet been collected into ``STATIC_ROOT``. Without this setting WhiteNoise
610
+ # only looks for files inside ``STATIC_ROOT`` and dashboards like the public
611
+ # traffic chart fail to load their JavaScript dependencies.
612
+ WHITENOISE_USE_FINDERS = True
613
+ WHITENOISE_AUTOREFRESH = DEBUG
614
+ MEDIA_URL = "/media/"
615
+ MEDIA_ROOT = BASE_DIR / "media"
616
+
617
+ # Email settings
618
+ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
619
+ DEFAULT_FROM_EMAIL = "arthexis@gmail.com"
620
+ SERVER_EMAIL = DEFAULT_FROM_EMAIL
621
+
622
+ # Default primary key field type
623
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
624
+
625
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
626
+
627
+ # GitHub issue reporting
628
+ GITHUB_ISSUE_REPORTING_ENABLED = True
629
+ GITHUB_ISSUE_REPORTING_COOLDOWN = 3600 # seconds
630
+
631
+ # Logging configuration
632
+ LOG_DIR = select_log_dir(BASE_DIR)
633
+ os.environ.setdefault("ARTHEXIS_LOG_DIR", str(LOG_DIR))
634
+ OLD_LOG_DIR = LOG_DIR / "old"
635
+ OLD_LOG_DIR.mkdir(parents=True, exist_ok=True)
636
+ LOG_FILE_NAME = "tests.log" if "test" in sys.argv else f"{socket.gethostname()}.log"
637
+
638
+ LOGGING = {
639
+ "version": 1,
640
+ "disable_existing_loggers": False,
641
+ "formatters": {
642
+ "standard": {
643
+ "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
644
+ }
645
+ },
646
+ "handlers": {
647
+ "file": {
648
+ "class": "config.logging.ActiveAppFileHandler",
649
+ "filename": str(LOG_DIR / LOG_FILE_NAME),
650
+ "when": "midnight",
651
+ "backupCount": 7,
652
+ "encoding": "utf-8",
653
+ "formatter": "standard",
654
+ },
655
+ "error_file": {
656
+ "class": "config.logging.ErrorFileHandler",
657
+ "filename": str(LOG_DIR / "error.log"),
658
+ "when": "midnight",
659
+ "backupCount": 7,
660
+ "encoding": "utf-8",
661
+ "formatter": "standard",
662
+ "level": "ERROR",
663
+ },
664
+ "console": {
665
+ "class": "logging.StreamHandler",
666
+ "level": "ERROR",
667
+ "formatter": "standard",
668
+ },
669
+ },
670
+ "root": {
671
+ "handlers": ["file", "error_file", "console"],
672
+ "level": "DEBUG",
673
+ },
674
+ }
675
+
676
+
677
+ # Celery configuration
678
+ CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "memory://")
679
+ CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "cache+memory://")
680
+ CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
681
+
682
+ CELERY_BEAT_SCHEDULE = {
683
+ "heartbeat": {
684
+ "task": "core.tasks.heartbeat",
685
+ "schedule": crontab(minute="*/5"),
686
+ },
687
+ "birthday_greetings": {
688
+ "task": "core.tasks.birthday_greetings",
689
+ "schedule": crontab(hour=9, minute=0),
690
+ },
691
+ }