arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.10.dist-info}/METADATA +63 -20
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/RECORD +39 -36
- config/settings.py +221 -23
- config/urls.py +6 -0
- core/admin.py +401 -35
- core/apps.py +3 -0
- core/auto_upgrade.py +57 -0
- core/backends.py +77 -3
- core/fields.py +93 -0
- core/models.py +212 -7
- core/reference_utils.py +97 -0
- core/sigil_builder.py +16 -3
- core/system.py +157 -143
- core/tasks.py +151 -8
- core/test_system_info.py +37 -1
- core/tests.py +288 -12
- core/user_data.py +103 -8
- core/views.py +257 -15
- nodes/admin.py +12 -4
- nodes/backends.py +109 -17
- nodes/models.py +205 -2
- nodes/tests.py +370 -1
- nodes/views.py +140 -7
- ocpp/admin.py +63 -3
- ocpp/consumers.py +252 -41
- ocpp/evcs.py +6 -3
- ocpp/models.py +49 -7
- ocpp/simulator.py +62 -5
- ocpp/store.py +30 -0
- ocpp/tests.py +384 -8
- ocpp/views.py +101 -76
- pages/context_processors.py +20 -0
- pages/forms.py +131 -0
- pages/tests.py +434 -13
- pages/urls.py +1 -0
- pages/views.py +334 -92
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.10.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."""
|
|
@@ -450,9 +468,16 @@ def cp_simulator(request):
|
|
|
450
468
|
cp_idx = int(request.POST.get("cp") or 1)
|
|
451
469
|
action = request.POST.get("action")
|
|
452
470
|
if action == "start":
|
|
471
|
+
ws_port_value = request.POST.get("ws_port")
|
|
472
|
+
if ws_port_value is None:
|
|
473
|
+
ws_port = int(default_ws_port) if default_ws_port else None
|
|
474
|
+
elif ws_port_value.strip():
|
|
475
|
+
ws_port = int(ws_port_value)
|
|
476
|
+
else:
|
|
477
|
+
ws_port = None
|
|
453
478
|
sim_params = dict(
|
|
454
479
|
host=request.POST.get("host") or default_host,
|
|
455
|
-
ws_port=
|
|
480
|
+
ws_port=ws_port,
|
|
456
481
|
cp_path=request.POST.get("cp_path") or default_cp_paths[cp_idx - 1],
|
|
457
482
|
serial_number=request.POST.get("serial_number")
|
|
458
483
|
or default_serial_numbers[cp_idx - 1],
|
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"
|
|
@@ -47,6 +49,11 @@ def nav_links(request):
|
|
|
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 app_name == "man":
|
|
56
|
+
module.menu = "Manuals"
|
|
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/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
|