arthexis 0.1.9__py3-none-any.whl → 0.1.11__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 (51) hide show
  1. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
  2. arthexis-0.1.11.dist-info/RECORD +99 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +245 -26
  5. config/urls.py +11 -4
  6. core/admin.py +585 -57
  7. core/apps.py +29 -1
  8. core/auto_upgrade.py +57 -0
  9. core/backends.py +115 -3
  10. core/environment.py +23 -5
  11. core/fields.py +93 -0
  12. core/mailer.py +3 -1
  13. core/models.py +482 -38
  14. core/reference_utils.py +108 -0
  15. core/sigil_builder.py +23 -5
  16. core/sigil_resolver.py +35 -4
  17. core/system.py +400 -140
  18. core/tasks.py +151 -8
  19. core/temp_passwords.py +181 -0
  20. core/test_system_info.py +97 -1
  21. core/tests.py +393 -15
  22. core/user_data.py +154 -16
  23. core/views.py +499 -20
  24. nodes/admin.py +149 -6
  25. nodes/backends.py +125 -18
  26. nodes/dns.py +203 -0
  27. nodes/models.py +498 -9
  28. nodes/tests.py +682 -3
  29. nodes/views.py +154 -7
  30. ocpp/admin.py +63 -3
  31. ocpp/consumers.py +255 -41
  32. ocpp/evcs.py +6 -3
  33. ocpp/models.py +52 -7
  34. ocpp/reference_utils.py +42 -0
  35. ocpp/simulator.py +62 -5
  36. ocpp/store.py +30 -0
  37. ocpp/test_rfid.py +169 -7
  38. ocpp/tests.py +414 -8
  39. ocpp/views.py +109 -76
  40. pages/admin.py +9 -1
  41. pages/context_processors.py +24 -4
  42. pages/defaults.py +14 -0
  43. pages/forms.py +131 -0
  44. pages/models.py +53 -14
  45. pages/tests.py +450 -14
  46. pages/urls.py +4 -0
  47. pages/views.py +419 -110
  48. arthexis-0.1.9.dist-info/RECORD +0 -92
  49. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
  50. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
  51. {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/top_level.txt +0 -0
ocpp/views.py CHANGED
@@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
9
9
  from django.shortcuts import render, get_object_or_404
10
10
  from django.core.paginator import Paginator
11
11
  from django.contrib.auth.decorators import login_required
12
+ from django.contrib.auth.views import redirect_to_login
12
13
  from django.utils.translation import gettext_lazy as _, gettext, ngettext
13
14
  from django.urls import NoReverseMatch, reverse
14
15
  from django.conf import settings
@@ -16,6 +17,8 @@ from django.utils import translation
16
17
 
17
18
  from utils.api import api_login_required
18
19
 
20
+ from nodes.models import Node
21
+
19
22
  from pages.utils import landing
20
23
  from core.liveupdate import live_update
21
24
 
@@ -229,7 +232,7 @@ def _diagnostics_payload(charger: Charger) -> dict[str, str | None]:
229
232
  def charger_list(request):
230
233
  """Return a JSON list of known chargers and state."""
231
234
  data = []
232
- for charger in Charger.objects.all():
235
+ for charger in Charger.objects.filter(public_display=True):
233
236
  cid = charger.charger_id
234
237
  sessions: list[tuple[Charger, Transaction]] = []
235
238
  tx_obj = store.get_transaction(cid, charger.connector_id)
@@ -279,42 +282,42 @@ def charger_list(request):
279
282
  charger,
280
283
  tx_obj if charger.connector_id is not None else (sessions if sessions else None),
281
284
  )
282
- data.append(
283
- {
284
- "charger_id": cid,
285
- "name": charger.name,
286
- "connector_id": charger.connector_id,
287
- "connector_slug": charger.connector_slug,
288
- "connector_label": charger.connector_label,
289
- "require_rfid": charger.require_rfid,
290
- "transaction": tx_data,
291
- "activeTransactions": active_transactions,
292
- "lastHeartbeat": (
293
- charger.last_heartbeat.isoformat()
294
- if charger.last_heartbeat
295
- else None
296
- ),
297
- "lastMeterValues": charger.last_meter_values,
298
- "firmwareStatus": charger.firmware_status,
299
- "firmwareStatusInfo": charger.firmware_status_info,
300
- "firmwareTimestamp": (
301
- charger.firmware_timestamp.isoformat()
302
- if charger.firmware_timestamp
303
- else None
304
- ),
305
- "connected": store.is_connected(cid, charger.connector_id),
306
- "lastStatus": charger.last_status or None,
307
- "lastErrorCode": charger.last_error_code or None,
308
- "lastStatusTimestamp": (
309
- charger.last_status_timestamp.isoformat()
310
- if charger.last_status_timestamp
311
- else None
312
- ),
313
- "lastStatusVendorInfo": charger.last_status_vendor_info,
314
- "status": state,
315
- "statusColor": color,
316
- }
317
- )
285
+ entry = {
286
+ "charger_id": cid,
287
+ "name": charger.name,
288
+ "connector_id": charger.connector_id,
289
+ "connector_slug": charger.connector_slug,
290
+ "connector_label": charger.connector_label,
291
+ "require_rfid": charger.require_rfid,
292
+ "transaction": tx_data,
293
+ "activeTransactions": active_transactions,
294
+ "lastHeartbeat": (
295
+ charger.last_heartbeat.isoformat()
296
+ if charger.last_heartbeat
297
+ else None
298
+ ),
299
+ "lastMeterValues": charger.last_meter_values,
300
+ "firmwareStatus": charger.firmware_status,
301
+ "firmwareStatusInfo": charger.firmware_status_info,
302
+ "firmwareTimestamp": (
303
+ charger.firmware_timestamp.isoformat()
304
+ if charger.firmware_timestamp
305
+ else None
306
+ ),
307
+ "connected": store.is_connected(cid, charger.connector_id),
308
+ "lastStatus": charger.last_status or None,
309
+ "lastErrorCode": charger.last_error_code or None,
310
+ "lastStatusTimestamp": (
311
+ charger.last_status_timestamp.isoformat()
312
+ if charger.last_status_timestamp
313
+ else None
314
+ ),
315
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
316
+ "status": state,
317
+ "statusColor": color,
318
+ }
319
+ entry.update(_diagnostics_payload(charger))
320
+ data.append(entry)
318
321
  return JsonResponse({"chargers": data})
319
322
 
320
323
 
@@ -375,48 +378,54 @@ def charger_detail(request, cid, connector=None):
375
378
  charger,
376
379
  tx_obj if charger.connector_id is not None else (sessions if sessions else None),
377
380
  )
378
- return JsonResponse(
379
- {
380
- "charger_id": cid,
381
- "connector_id": charger.connector_id,
382
- "connector_slug": connector_slug,
383
- "name": charger.name,
384
- "require_rfid": charger.require_rfid,
385
- "transaction": tx_data,
386
- "activeTransactions": active_transactions,
387
- "lastHeartbeat": (
388
- charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
389
- ),
390
- "lastMeterValues": charger.last_meter_values,
391
- "firmwareStatus": charger.firmware_status,
392
- "firmwareStatusInfo": charger.firmware_status_info,
393
- "firmwareTimestamp": (
394
- charger.firmware_timestamp.isoformat()
395
- if charger.firmware_timestamp
396
- else None
397
- ),
398
- "log": log,
399
- "lastStatus": charger.last_status or None,
400
- "lastErrorCode": charger.last_error_code or None,
401
- "lastStatusTimestamp": (
402
- charger.last_status_timestamp.isoformat()
403
- if charger.last_status_timestamp
404
- else None
405
- ),
406
- "lastStatusVendorInfo": charger.last_status_vendor_info,
407
- "status": state,
408
- "statusColor": color,
409
- }
410
- )
381
+ payload = {
382
+ "charger_id": cid,
383
+ "connector_id": charger.connector_id,
384
+ "connector_slug": connector_slug,
385
+ "name": charger.name,
386
+ "require_rfid": charger.require_rfid,
387
+ "transaction": tx_data,
388
+ "activeTransactions": active_transactions,
389
+ "lastHeartbeat": (
390
+ charger.last_heartbeat.isoformat() if charger.last_heartbeat else None
391
+ ),
392
+ "lastMeterValues": charger.last_meter_values,
393
+ "firmwareStatus": charger.firmware_status,
394
+ "firmwareStatusInfo": charger.firmware_status_info,
395
+ "firmwareTimestamp": (
396
+ charger.firmware_timestamp.isoformat()
397
+ if charger.firmware_timestamp
398
+ else None
399
+ ),
400
+ "log": log,
401
+ "lastStatus": charger.last_status or None,
402
+ "lastErrorCode": charger.last_error_code or None,
403
+ "lastStatusTimestamp": (
404
+ charger.last_status_timestamp.isoformat()
405
+ if charger.last_status_timestamp
406
+ else None
407
+ ),
408
+ "lastStatusVendorInfo": charger.last_status_vendor_info,
409
+ "status": state,
410
+ "statusColor": color,
411
+ }
412
+ payload.update(_diagnostics_payload(charger))
413
+ return JsonResponse(payload)
411
414
 
412
415
 
413
- @login_required
414
- @landing("Dashboard")
416
+ @landing("OCPP CSMS Dashboard")
415
417
  @live_update()
416
418
  def dashboard(request):
417
419
  """Landing page listing all known chargers and their status."""
420
+ node = Node.get_local()
421
+ role = node.role if node else None
422
+ is_constellation = bool(role and role.name == "Constellation")
423
+ if not request.user.is_authenticated and not is_constellation:
424
+ return redirect_to_login(
425
+ request.get_full_path(), login_url=reverse("pages:login")
426
+ )
418
427
  chargers = []
419
- for charger in Charger.objects.all():
428
+ for charger in Charger.objects.filter(public_display=True):
420
429
  tx_obj = store.get_transaction(charger.charger_id, charger.connector_id)
421
430
  if not tx_obj:
422
431
  tx_obj = (
@@ -426,11 +435,20 @@ def dashboard(request):
426
435
  )
427
436
  state, color = _charger_state(charger, tx_obj)
428
437
  chargers.append({"charger": charger, "state": state, "color": color})
429
- return render(request, "ocpp/dashboard.html", {"chargers": chargers})
438
+ scheme = "wss" if request.is_secure() else "ws"
439
+ host = request.get_host()
440
+ ws_url = f"{scheme}://{host}/ocpp/<CHARGE_POINT_ID>/"
441
+ context = {
442
+ "chargers": chargers,
443
+ "show_demo_notice": is_constellation,
444
+ "demo_ws_url": ws_url,
445
+ "ws_rate_limit": store.MAX_CONNECTIONS_PER_IP,
446
+ }
447
+ return render(request, "ocpp/dashboard.html", context)
430
448
 
431
449
 
432
450
  @login_required
433
- @landing("CP Simulator")
451
+ @landing("Charge Point Simulator")
434
452
  @live_update()
435
453
  def cp_simulator(request):
436
454
  """Public landing page to control the OCPP charge point simulator."""
@@ -446,13 +464,21 @@ def cp_simulator(request):
446
464
  default_vins = ["WP0ZZZ00000000000", "WAUZZZ00000000000"]
447
465
 
448
466
  message = ""
467
+ dashboard_link: str | None = None
449
468
  if request.method == "POST":
450
469
  cp_idx = int(request.POST.get("cp") or 1)
451
470
  action = request.POST.get("action")
452
471
  if action == "start":
472
+ ws_port_value = request.POST.get("ws_port")
473
+ if ws_port_value is None:
474
+ ws_port = int(default_ws_port) if default_ws_port else None
475
+ elif ws_port_value.strip():
476
+ ws_port = int(ws_port_value)
477
+ else:
478
+ ws_port = None
453
479
  sim_params = dict(
454
480
  host=request.POST.get("host") or default_host,
455
- ws_port=int(request.POST.get("ws_port") or default_ws_port),
481
+ ws_port=ws_port,
456
482
  cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
457
483
  serial_number=request.POST.get("serial_number")
458
484
  or default_serial_numbers[cp_idx - 1],
@@ -475,6 +501,12 @@ def cp_simulator(request):
475
501
  started, status, log_file = _start_simulator(sim_params, cp=cp_idx)
476
502
  if started:
477
503
  message = f"CP{cp_idx} started: {status}. Logs: {log_file}"
504
+ try:
505
+ dashboard_link = reverse(
506
+ "charger-status", args=[sim_params["cp_path"]]
507
+ )
508
+ except NoReverseMatch: # pragma: no cover - defensive
509
+ dashboard_link = None
478
510
  else:
479
511
  message = f"CP{cp_idx} {status}. Logs: {log_file}"
480
512
  except Exception as exc: # pragma: no cover - unexpected
@@ -501,6 +533,7 @@ def cp_simulator(request):
501
533
 
502
534
  context = {
503
535
  "message": message,
536
+ "dashboard_link": dashboard_link,
504
537
  "states": state_list,
505
538
  "default_host": default_host,
506
539
  "default_ws_port": default_ws_port,
pages/admin.py CHANGED
@@ -28,6 +28,7 @@ from .models import (
28
28
  Landing,
29
29
  Favorite,
30
30
  ViewHistory,
31
+ UserManual,
31
32
  )
32
33
  from django.contrib.contenttypes.models import ContentType
33
34
  from core.user_data import EntityModelAdmin
@@ -153,7 +154,7 @@ class ApplicationModuleInline(admin.TabularInline):
153
154
  @admin.register(Application)
154
155
  class ApplicationAdmin(EntityModelAdmin):
155
156
  form = ApplicationForm
156
- list_display = ("name", "app_verbose_name", "installed")
157
+ list_display = ("name", "app_verbose_name", "description", "installed")
157
158
  readonly_fields = ("installed",)
158
159
  inlines = [ApplicationModuleInline]
159
160
 
@@ -180,6 +181,13 @@ class ModuleAdmin(EntityModelAdmin):
180
181
  inlines = [LandingInline]
181
182
 
182
183
 
184
+ @admin.register(UserManual)
185
+ class UserManualAdmin(EntityModelAdmin):
186
+ list_display = ("title", "slug", "languages", "is_seed_data", "is_user_data")
187
+ search_fields = ("title", "slug", "description")
188
+ list_filter = ("is_seed_data", "is_user_data")
189
+
190
+
183
191
  @admin.register(ViewHistory)
184
192
  class ViewHistoryAdmin(EntityModelAdmin):
185
193
  date_hierarchy = "visited_at"
@@ -4,6 +4,8 @@ from django.conf import settings
4
4
  from pathlib import Path
5
5
  from types import SimpleNamespace
6
6
  from nodes.models import Node
7
+ from core.models import Reference
8
+ from core.reference_utils import filter_visible_references
7
9
  from .models import Module
8
10
 
9
11
  _favicon_path = Path(settings.BASE_DIR) / "pages" / "fixtures" / "data" / "favicon.txt"
@@ -37,16 +39,21 @@ def nav_links(request):
37
39
  except Resolver404:
38
40
  continue
39
41
  view_func = match.func
40
- requires_login = getattr(view_func, "login_required", False) or hasattr(
41
- view_func, "login_url"
42
- )
42
+ requires_login = bool(getattr(view_func, "login_required", False))
43
+ if not requires_login and hasattr(view_func, "login_url"):
44
+ requires_login = True
43
45
  staff_only = getattr(view_func, "staff_required", False)
44
46
  if requires_login and not request.user.is_authenticated:
45
- continue
47
+ setattr(landing, "requires_login", True)
46
48
  if staff_only and not request.user.is_staff:
47
49
  continue
48
50
  landings.append(landing)
49
51
  if landings:
52
+ app_name = getattr(module.application, "name", "").lower()
53
+ if app_name == "awg":
54
+ module.menu = "Calculate"
55
+ elif module.path.rstrip("/").lower() == "/man":
56
+ module.menu = "Manual"
50
57
  module.enabled_landings = landings
51
58
  valid_modules.append(module)
52
59
  if request.path.startswith(module.path):
@@ -79,7 +86,20 @@ def nav_links(request):
79
86
  if not favicon_url:
80
87
  favicon_url = _DEFAULT_FAVICON
81
88
 
89
+ header_refs_qs = (
90
+ Reference.objects.filter(show_in_header=True)
91
+ .exclude(value="")
92
+ .prefetch_related("roles", "features", "sites")
93
+ )
94
+ header_references = filter_visible_references(
95
+ header_refs_qs,
96
+ request=request,
97
+ site=site,
98
+ node=node,
99
+ )
100
+
82
101
  return {
83
102
  "nav_modules": valid_modules,
84
103
  "favicon_url": favicon_url,
104
+ "header_references": header_references,
85
105
  }
pages/defaults.py ADDED
@@ -0,0 +1,14 @@
1
+ """Default configuration for the pages application."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict
5
+
6
+ DEFAULT_APPLICATION_DESCRIPTIONS: Dict[str, str] = {
7
+ "awg": "Power, Energy and Cost calculations.",
8
+ "core": "Support for Business Processes and monetization.",
9
+ "ocpp": "Compatibility with Standards and Good Practices.",
10
+ "nodes": "System and Node-level operations,",
11
+ "pages": "Scheduling, Periodicity and Event Signaling,",
12
+ "teams": "Identity, Entitlements and Access Controls.",
13
+ "man": "User QA, Continuity Design and Chaos Testing.",
14
+ }
pages/forms.py ADDED
@@ -0,0 +1,131 @@
1
+ """Forms for the pages app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django import forms
6
+ from django.contrib.auth import authenticate
7
+ from django.contrib.auth.forms import AuthenticationForm
8
+ from django.core.exceptions import ValidationError
9
+ from django.utils.translation import gettext_lazy as _
10
+ from django.views.decorators.debug import sensitive_variables
11
+
12
+
13
+ class AuthenticatorLoginForm(AuthenticationForm):
14
+ """Authentication form that supports password or authenticator codes."""
15
+
16
+ otp_token = forms.CharField(
17
+ label=_("Authenticator code"),
18
+ required=False,
19
+ widget=forms.TextInput(
20
+ attrs={
21
+ "autocomplete": "one-time-code",
22
+ "inputmode": "numeric",
23
+ "pattern": "[0-9]*",
24
+ }
25
+ ),
26
+ )
27
+ auth_method = forms.CharField(required=False, widget=forms.HiddenInput(), initial="password")
28
+
29
+ error_messages = {
30
+ **AuthenticationForm.error_messages,
31
+ "invalid_token": _("The authenticator code is invalid or has expired."),
32
+ "token_required": _("Enter the code from your authenticator app."),
33
+ "password_required": _("Enter your password."),
34
+ }
35
+
36
+ def __init__(self, request=None, *args, **kwargs):
37
+ super().__init__(request=request, *args, **kwargs)
38
+ self.fields["password"].required = False
39
+ self.fields["otp_token"].strip = True
40
+ self.fields["auth_method"].initial = "password"
41
+ self.verified_device = None
42
+
43
+ def get_invalid_token_error(self) -> ValidationError:
44
+ return ValidationError(self.error_messages["invalid_token"], code="invalid_token")
45
+
46
+ def get_token_required_error(self) -> ValidationError:
47
+ return ValidationError(self.error_messages["token_required"], code="token_required")
48
+
49
+ def get_password_required_error(self) -> ValidationError:
50
+ return ValidationError(self.error_messages["password_required"], code="password_required")
51
+
52
+ @sensitive_variables()
53
+ def clean(self):
54
+ username = self.cleaned_data.get("username")
55
+ method = (self.cleaned_data.get("auth_method") or "password").lower()
56
+ if method not in {"password", "otp"}:
57
+ method = "password"
58
+ self.cleaned_data["auth_method"] = method
59
+
60
+ if username is not None:
61
+ if method == "otp":
62
+ token = (self.cleaned_data.get("otp_token") or "").strip().replace(" ", "")
63
+ if not token:
64
+ raise self.get_token_required_error()
65
+ self.user_cache = authenticate(
66
+ self.request,
67
+ username=username,
68
+ otp_token=token,
69
+ )
70
+ if self.user_cache is None:
71
+ raise self.get_invalid_token_error()
72
+ self.cleaned_data["otp_token"] = token
73
+ self.verified_device = getattr(self.user_cache, "otp_device", None)
74
+ else:
75
+ password = self.cleaned_data.get("password")
76
+ if not password:
77
+ raise self.get_password_required_error()
78
+ self.user_cache = authenticate(
79
+ self.request, username=username, password=password
80
+ )
81
+ if self.user_cache is None:
82
+ raise self.get_invalid_login_error()
83
+ self.confirm_login_allowed(self.user_cache)
84
+
85
+ return self.cleaned_data
86
+
87
+ def get_verified_device(self):
88
+ return self.verified_device
89
+
90
+
91
+ class AuthenticatorEnrollmentForm(forms.Form):
92
+ """Form used to confirm a pending authenticator enrollment."""
93
+
94
+ token = forms.CharField(
95
+ label=_("Authenticator code"),
96
+ min_length=6,
97
+ max_length=8,
98
+ widget=forms.TextInput(
99
+ attrs={
100
+ "autocomplete": "one-time-code",
101
+ "inputmode": "numeric",
102
+ "pattern": "[0-9]*",
103
+ }
104
+ ),
105
+ )
106
+
107
+ error_messages = {
108
+ "invalid_token": _("The provided code is invalid or has expired."),
109
+ "missing_device": _("Generate a new authenticator secret before confirming it."),
110
+ }
111
+
112
+ def __init__(self, *args, device=None, **kwargs):
113
+ self.device = device
114
+ super().__init__(*args, **kwargs)
115
+
116
+ def clean_token(self):
117
+ token = (self.cleaned_data.get("token") or "").strip().replace(" ", "")
118
+ if not token:
119
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
120
+ if self.device is None:
121
+ raise forms.ValidationError(self.error_messages["missing_device"], code="missing_device")
122
+ try:
123
+ verified = self.device.verify_token(token)
124
+ except Exception:
125
+ verified = False
126
+ if not verified:
127
+ raise forms.ValidationError(self.error_messages["invalid_token"], code="invalid_token")
128
+ return token
129
+
130
+ def get_verified_device(self):
131
+ return self.device
pages/models.py CHANGED
@@ -111,6 +111,7 @@ class Module(Entity):
111
111
  return
112
112
  patterns = getattr(urlconf, "urlpatterns", [])
113
113
  created = False
114
+ normalized_module = self.path.strip("/")
114
115
 
115
116
  def _walk(patterns, prefix=""):
116
117
  nonlocal created
@@ -118,17 +119,34 @@ class Module(Entity):
118
119
  if isinstance(pattern, URLPattern):
119
120
  callback = pattern.callback
120
121
  if getattr(callback, "landing", False):
121
- Landing.objects.get_or_create(
122
- module=self,
123
- path=f"{self.path}{prefix}{str(pattern.pattern)}",
124
- defaults={
125
- "label": getattr(
126
- callback,
127
- "landing_label",
128
- callback.__name__.replace("_", " ").title(),
129
- )
130
- },
131
- )
122
+ pattern_path = str(pattern.pattern)
123
+ relative = f"{prefix}{pattern_path}"
124
+ if normalized_module and relative.startswith(normalized_module):
125
+ full_path = f"/{relative}"
126
+ Landing.objects.update_or_create(
127
+ module=self,
128
+ path=full_path,
129
+ defaults={
130
+ "label": getattr(
131
+ callback,
132
+ "landing_label",
133
+ callback.__name__.replace("_", " ").title(),
134
+ )
135
+ },
136
+ )
137
+ else:
138
+ full_path = f"{self.path}{relative}"
139
+ Landing.objects.get_or_create(
140
+ module=self,
141
+ path=full_path,
142
+ defaults={
143
+ "label": getattr(
144
+ callback,
145
+ "landing_label",
146
+ callback.__name__.replace("_", " ").title(),
147
+ )
148
+ },
149
+ )
132
150
  created = True
133
151
  else:
134
152
  _walk(
@@ -192,6 +210,7 @@ class Landing(Entity):
192
210
  return f"{self.label} ({self.path})"
193
211
 
194
212
  def save(self, *args, **kwargs):
213
+ existing = None
195
214
  if not self.pk:
196
215
  existing = (
197
216
  type(self).objects.filter(module=self.module, path=self.path).first()
@@ -200,10 +219,30 @@ class Landing(Entity):
200
219
  self.pk = existing.pk
201
220
  super().save(*args, **kwargs)
202
221
 
203
- def natural_key(self): # pragma: no cover - simple representation
204
- return (self.module.node_role.name, self.module.path, self.path)
205
222
 
206
- natural_key.dependencies = ["nodes.NodeRole", "pages.Module"]
223
+ class UserManual(Entity):
224
+ slug = models.SlugField(unique=True)
225
+ title = models.CharField(max_length=200)
226
+ description = models.CharField(max_length=200)
227
+ languages = models.CharField(
228
+ max_length=100,
229
+ blank=True,
230
+ default="",
231
+ help_text="Comma-separated 2-letter language codes",
232
+ )
233
+ content_html = models.TextField()
234
+ content_pdf = models.TextField(help_text="Base64 encoded PDF")
235
+
236
+ class Meta:
237
+ db_table = "man_usermanual"
238
+ verbose_name = "User Manual"
239
+ verbose_name_plural = "User Manuals"
240
+
241
+ def __str__(self): # pragma: no cover - simple representation
242
+ return self.title
243
+
244
+ def natural_key(self): # pragma: no cover - simple representation
245
+ return (self.slug,)
207
246
 
208
247
 
209
248
  class ViewHistory(Entity):