arthexis 0.1.13__py3-none-any.whl → 0.1.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/METADATA +224 -221
  2. arthexis-0.1.15.dist-info/RECORD +110 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3795 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +149 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3637 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +840 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +952 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2168 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2201 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1764 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3830 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +769 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +209 -153
  99. pages/models.py +643 -426
  100. pages/tasks.py +74 -0
  101. pages/tests.py +3025 -2200
  102. pages/urls.py +26 -25
  103. pages/utils.py +23 -12
  104. pages/views.py +1176 -1128
  105. arthexis-0.1.13.dist-info/RECORD +0 -105
  106. nodes/actions.py +0 -70
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/WHEEL +0 -0
  108. {arthexis-0.1.13.dist-info → arthexis-0.1.15.dist-info}/top_level.txt +0 -0
config/settings.py CHANGED
@@ -1,682 +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, 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"),
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
- "console": {
656
- "class": "logging.StreamHandler",
657
- "level": "ERROR",
658
- "formatter": "standard",
659
- },
660
- },
661
- "root": {
662
- "handlers": ["file", "console"],
663
- "level": "DEBUG",
664
- },
665
- }
666
-
667
-
668
- # Celery configuration
669
- CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "memory://")
670
- CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "cache+memory://")
671
- CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
672
-
673
- CELERY_BEAT_SCHEDULE = {
674
- "heartbeat": {
675
- "task": "core.tasks.heartbeat",
676
- "schedule": crontab(minute="*/5"),
677
- },
678
- "birthday_greetings": {
679
- "task": "core.tasks.birthday_greetings",
680
- "schedule": crontab(hour=9, minute=0),
681
- },
682
- }
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
+ }