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.
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/METADATA +76 -23
- arthexis-0.1.11.dist-info/RECORD +99 -0
- config/context_processors.py +1 -0
- config/settings.py +245 -26
- config/urls.py +11 -4
- core/admin.py +585 -57
- core/apps.py +29 -1
- core/auto_upgrade.py +57 -0
- core/backends.py +115 -3
- core/environment.py +23 -5
- core/fields.py +93 -0
- core/mailer.py +3 -1
- core/models.py +482 -38
- core/reference_utils.py +108 -0
- core/sigil_builder.py +23 -5
- core/sigil_resolver.py +35 -4
- core/system.py +400 -140
- core/tasks.py +151 -8
- core/temp_passwords.py +181 -0
- core/test_system_info.py +97 -1
- core/tests.py +393 -15
- core/user_data.py +154 -16
- core/views.py +499 -20
- nodes/admin.py +149 -6
- nodes/backends.py +125 -18
- nodes/dns.py +203 -0
- nodes/models.py +498 -9
- nodes/tests.py +682 -3
- nodes/views.py +154 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +255 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +52 -7
- ocpp/reference_utils.py +42 -0
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/test_rfid.py +169 -7
- ocpp/tests.py +414 -8
- ocpp/views.py +109 -76
- pages/admin.py +9 -1
- pages/context_processors.py +24 -4
- pages/defaults.py +14 -0
- pages/forms.py +131 -0
- pages/models.py +53 -14
- pages/tests.py +450 -14
- pages/urls.py +4 -0
- pages/views.py +419 -110
- arthexis-0.1.9.dist-info/RECORD +0 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.11.dist-info}/licenses/LICENSE +0 -0
- {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.
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
@
|
|
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.
|
|
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
|
-
|
|
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("
|
|
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=
|
|
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"
|
pages/context_processors.py
CHANGED
|
@@ -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)
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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):
|