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