arthexis 0.1.26__py3-none-any.whl → 0.1.28__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.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/urls.py
CHANGED
|
@@ -5,6 +5,11 @@ from . import views
|
|
|
5
5
|
urlpatterns = [
|
|
6
6
|
path("cpms/dashboard/", views.dashboard, name="ocpp-dashboard"),
|
|
7
7
|
path("evcs/simulator/", views.cp_simulator, name="cp-simulator"),
|
|
8
|
+
path(
|
|
9
|
+
"firmware/<int:deployment_id>/<slug:token>/",
|
|
10
|
+
views.firmware_download,
|
|
11
|
+
name="cp-firmware-download",
|
|
12
|
+
),
|
|
8
13
|
path("chargers/", views.charger_list, name="charger-list"),
|
|
9
14
|
path("chargers/<str:cid>/", views.charger_detail, name="charger-detail"),
|
|
10
15
|
path(
|
ocpp/views.py
CHANGED
|
@@ -30,7 +30,13 @@ from core.liveupdate import live_update
|
|
|
30
30
|
from django.utils.dateparse import parse_datetime
|
|
31
31
|
|
|
32
32
|
from . import store
|
|
33
|
-
from .models import
|
|
33
|
+
from .models import (
|
|
34
|
+
Transaction,
|
|
35
|
+
Charger,
|
|
36
|
+
DataTransferMessage,
|
|
37
|
+
RFID,
|
|
38
|
+
CPFirmwareDeployment,
|
|
39
|
+
)
|
|
34
40
|
from .evcs import (
|
|
35
41
|
_start_simulator,
|
|
36
42
|
_stop_simulator,
|
|
@@ -44,23 +50,53 @@ CALL_ACTION_LABELS = {
|
|
|
44
50
|
"RemoteStartTransaction": _("Remote start transaction"),
|
|
45
51
|
"RemoteStopTransaction": _("Remote stop transaction"),
|
|
46
52
|
"ChangeAvailability": _("Change availability"),
|
|
53
|
+
"ChangeConfiguration": _("Change configuration"),
|
|
47
54
|
"DataTransfer": _("Data transfer"),
|
|
48
55
|
"Reset": _("Reset"),
|
|
49
56
|
"TriggerMessage": _("Trigger message"),
|
|
50
57
|
"ReserveNow": _("Reserve connector"),
|
|
58
|
+
"ClearCache": _("Clear cache"),
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
CALL_EXPECTED_STATUSES: dict[str, set[str]] = {
|
|
54
62
|
"RemoteStartTransaction": {"Accepted"},
|
|
55
63
|
"RemoteStopTransaction": {"Accepted"},
|
|
56
64
|
"ChangeAvailability": {"Accepted", "Scheduled"},
|
|
65
|
+
"ChangeConfiguration": {"Accepted", "Rejected", "RebootRequired"},
|
|
57
66
|
"DataTransfer": {"Accepted"},
|
|
58
67
|
"Reset": {"Accepted"},
|
|
59
68
|
"TriggerMessage": {"Accepted"},
|
|
60
69
|
"ReserveNow": {"Accepted"},
|
|
70
|
+
"ClearCache": {"Accepted", "Rejected"},
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
|
|
74
|
+
def firmware_download(request, deployment_id: int, token: str):
|
|
75
|
+
deployment = get_object_or_404(
|
|
76
|
+
CPFirmwareDeployment,
|
|
77
|
+
pk=deployment_id,
|
|
78
|
+
download_token=token,
|
|
79
|
+
)
|
|
80
|
+
expires = deployment.download_token_expires_at
|
|
81
|
+
if expires and timezone.now() > expires:
|
|
82
|
+
return HttpResponse(status=403)
|
|
83
|
+
firmware = deployment.firmware
|
|
84
|
+
if firmware is None:
|
|
85
|
+
raise Http404
|
|
86
|
+
payload = firmware.get_payload_bytes()
|
|
87
|
+
if not payload:
|
|
88
|
+
raise Http404
|
|
89
|
+
content_type = firmware.content_type or "application/octet-stream"
|
|
90
|
+
response = HttpResponse(payload, content_type=content_type)
|
|
91
|
+
filename = firmware.filename or f"firmware_{firmware.pk or deployment.pk}"
|
|
92
|
+
safe_filename = filename.replace("\r", "").replace("\n", "").replace("\"", "")
|
|
93
|
+
response["Content-Disposition"] = f'attachment; filename="{safe_filename}"'
|
|
94
|
+
response["Content-Length"] = str(len(payload))
|
|
95
|
+
deployment.downloaded_at = timezone.now()
|
|
96
|
+
deployment.save(update_fields=["downloaded_at", "updated_at"])
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
|
|
64
100
|
def _format_details(value: object) -> str:
|
|
65
101
|
"""Return a JSON representation of ``value`` suitable for error messages."""
|
|
66
102
|
|
|
@@ -119,6 +155,7 @@ def _evaluate_pending_call_result(
|
|
|
119
155
|
if expected_statuses is not None:
|
|
120
156
|
status_value = str(payload_dict.get("status") or "").strip()
|
|
121
157
|
normalized_expected = {value.casefold() for value in expected_statuses if value}
|
|
158
|
+
remaining = {k: v for k, v in payload_dict.items() if k != "status"}
|
|
122
159
|
if not status_value:
|
|
123
160
|
detail = _("%(action)s response did not include a status.") % {
|
|
124
161
|
"action": action_label,
|
|
@@ -129,7 +166,15 @@ def _evaluate_pending_call_result(
|
|
|
129
166
|
"action": action_label,
|
|
130
167
|
"status": status_value,
|
|
131
168
|
}
|
|
132
|
-
|
|
169
|
+
extra = _format_details(remaining)
|
|
170
|
+
if extra:
|
|
171
|
+
detail += " " + _("Details: %(details)s") % {"details": extra}
|
|
172
|
+
return False, detail, 400
|
|
173
|
+
if status_value.casefold() == "rejected":
|
|
174
|
+
detail = _("%(action)s rejected with status %(status)s.") % {
|
|
175
|
+
"action": action_label,
|
|
176
|
+
"status": status_value,
|
|
177
|
+
}
|
|
133
178
|
extra = _format_details(remaining)
|
|
134
179
|
if extra:
|
|
135
180
|
detail += " " + _("Details: %(details)s") % {"details": extra}
|
|
@@ -401,72 +446,61 @@ def _collect_status_events(
|
|
|
401
446
|
keys.append(store.identity_key(serial, None))
|
|
402
447
|
keys.append(store.pending_key(serial))
|
|
403
448
|
|
|
404
|
-
seen_entries: set[str] = set()
|
|
405
449
|
events: list[tuple[datetime, str]] = []
|
|
406
450
|
latest_before_window: tuple[datetime, str] | None = None
|
|
407
451
|
|
|
408
|
-
for
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if len(entry) < 24:
|
|
414
|
-
continue
|
|
415
|
-
timestamp_raw = entry[:23]
|
|
416
|
-
message = entry[24:].strip()
|
|
417
|
-
try:
|
|
418
|
-
log_timestamp = datetime.strptime(
|
|
419
|
-
timestamp_raw, "%Y-%m-%d %H:%M:%S.%f"
|
|
420
|
-
).replace(tzinfo=dt_timezone.utc)
|
|
421
|
-
except ValueError:
|
|
422
|
-
continue
|
|
452
|
+
for entry in store.iter_log_entries(keys, log_type="charger", since=window_start):
|
|
453
|
+
if len(entry.text) < 24:
|
|
454
|
+
continue
|
|
455
|
+
message = entry.text[24:].strip()
|
|
456
|
+
log_timestamp = entry.timestamp
|
|
423
457
|
|
|
424
|
-
|
|
425
|
-
|
|
458
|
+
event_time = log_timestamp
|
|
459
|
+
status_bucket: str | None = None
|
|
426
460
|
|
|
427
|
-
|
|
428
|
-
|
|
461
|
+
if message.startswith("StatusNotification processed:"):
|
|
462
|
+
payload_text = message.split(":", 1)[1].strip()
|
|
463
|
+
try:
|
|
464
|
+
payload = json.loads(payload_text)
|
|
465
|
+
except json.JSONDecodeError:
|
|
466
|
+
continue
|
|
467
|
+
target_id = payload.get("connectorId")
|
|
468
|
+
if connector_id is not None:
|
|
429
469
|
try:
|
|
430
|
-
|
|
431
|
-
except
|
|
470
|
+
normalized_target = int(target_id)
|
|
471
|
+
except (TypeError, ValueError):
|
|
472
|
+
normalized_target = None
|
|
473
|
+
if normalized_target not in {connector_id, None}:
|
|
432
474
|
continue
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
event_time = parsed
|
|
452
|
-
elif message.startswith("Connected"):
|
|
453
|
-
status_bucket = "available"
|
|
454
|
-
elif message.startswith("Closed"):
|
|
455
|
-
status_bucket = "offline"
|
|
456
|
-
|
|
457
|
-
if not status_bucket:
|
|
458
|
-
continue
|
|
475
|
+
raw_status = payload.get("status")
|
|
476
|
+
status_bucket = _normalize_timeline_status(
|
|
477
|
+
raw_status if isinstance(raw_status, str) else None
|
|
478
|
+
)
|
|
479
|
+
payload_timestamp = payload.get("timestamp")
|
|
480
|
+
if isinstance(payload_timestamp, str):
|
|
481
|
+
parsed = parse_datetime(payload_timestamp)
|
|
482
|
+
if parsed is not None:
|
|
483
|
+
if timezone.is_naive(parsed):
|
|
484
|
+
parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
|
|
485
|
+
event_time = parsed
|
|
486
|
+
elif message.startswith("Connected"):
|
|
487
|
+
status_bucket = "available"
|
|
488
|
+
elif message.startswith("Closed"):
|
|
489
|
+
status_bucket = "offline"
|
|
490
|
+
|
|
491
|
+
if not status_bucket:
|
|
492
|
+
continue
|
|
459
493
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
494
|
+
if event_time < window_start:
|
|
495
|
+
if (
|
|
496
|
+
latest_before_window is None
|
|
497
|
+
or event_time > latest_before_window[0]
|
|
498
|
+
):
|
|
499
|
+
latest_before_window = (event_time, status_bucket)
|
|
500
|
+
break
|
|
501
|
+
if event_time > window_end:
|
|
502
|
+
continue
|
|
503
|
+
events.append((event_time, status_bucket))
|
|
470
504
|
|
|
471
505
|
events.sort(key=lambda item: item[0])
|
|
472
506
|
|
|
@@ -1695,17 +1729,9 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1695
1729
|
limit_choice = "20"
|
|
1696
1730
|
limit_index = allowed_values.index(limit_choice)
|
|
1697
1731
|
|
|
1698
|
-
log_entries_all = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1699
1732
|
download_requested = request.GET.get("download") == "1"
|
|
1700
|
-
if download_requested:
|
|
1701
|
-
download_content = "\n".join(log_entries_all)
|
|
1702
|
-
if download_content and not download_content.endswith("\n"):
|
|
1703
|
-
download_content = f"{download_content}\n"
|
|
1704
|
-
response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
|
|
1705
|
-
response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
|
|
1706
|
-
return response
|
|
1707
1733
|
|
|
1708
|
-
|
|
1734
|
+
limit_value: int | None = None
|
|
1709
1735
|
if limit_choice != "all":
|
|
1710
1736
|
try:
|
|
1711
1737
|
limit_value = int(limit_choice)
|
|
@@ -1713,7 +1739,19 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1713
1739
|
limit_value = 20
|
|
1714
1740
|
limit_choice = "20"
|
|
1715
1741
|
limit_index = allowed_values.index(limit_choice)
|
|
1716
|
-
|
|
1742
|
+
log_entries: list[str]
|
|
1743
|
+
if download_requested:
|
|
1744
|
+
log_entries = list(store.get_logs(target_id, log_type=log_type) or [])
|
|
1745
|
+
download_content = "\n".join(log_entries)
|
|
1746
|
+
if download_content and not download_content.endswith("\n"):
|
|
1747
|
+
download_content = f"{download_content}\n"
|
|
1748
|
+
response = HttpResponse(download_content, content_type="text/plain; charset=utf-8")
|
|
1749
|
+
response["Content-Disposition"] = f'attachment; filename="{download_filename}"'
|
|
1750
|
+
return response
|
|
1751
|
+
|
|
1752
|
+
log_entries = list(
|
|
1753
|
+
store.get_logs(target_id, log_type=log_type, limit=limit_value) or []
|
|
1754
|
+
)
|
|
1717
1755
|
|
|
1718
1756
|
download_params = request.GET.copy()
|
|
1719
1757
|
download_params["download"] = "1"
|
|
@@ -1896,6 +1934,70 @@ def dispatch_action(request, cid, connector=None):
|
|
|
1896
1934
|
Charger.objects.filter(pk=charger_obj.pk).update(**updates)
|
|
1897
1935
|
for field, value in updates.items():
|
|
1898
1936
|
setattr(charger_obj, field, value)
|
|
1937
|
+
elif action == "change_configuration":
|
|
1938
|
+
raw_key = data.get("key")
|
|
1939
|
+
if not isinstance(raw_key, str) or not raw_key.strip():
|
|
1940
|
+
return JsonResponse({"detail": "key required"}, status=400)
|
|
1941
|
+
key_value = raw_key.strip()
|
|
1942
|
+
raw_value = data.get("value", None)
|
|
1943
|
+
value_included = False
|
|
1944
|
+
value_text: str | None = None
|
|
1945
|
+
if raw_value is not None:
|
|
1946
|
+
if isinstance(raw_value, (str, int, float, bool)):
|
|
1947
|
+
value_included = True
|
|
1948
|
+
if isinstance(raw_value, str):
|
|
1949
|
+
value_text = raw_value
|
|
1950
|
+
else:
|
|
1951
|
+
value_text = str(raw_value)
|
|
1952
|
+
else:
|
|
1953
|
+
return JsonResponse(
|
|
1954
|
+
{"detail": "value must be a string, number, or boolean"},
|
|
1955
|
+
status=400,
|
|
1956
|
+
)
|
|
1957
|
+
payload = {"key": key_value}
|
|
1958
|
+
if value_included:
|
|
1959
|
+
payload["value"] = value_text
|
|
1960
|
+
message_id = uuid.uuid4().hex
|
|
1961
|
+
ocpp_action = "ChangeConfiguration"
|
|
1962
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1963
|
+
msg = json.dumps([2, message_id, "ChangeConfiguration", payload])
|
|
1964
|
+
elif action == "clear_cache":
|
|
1965
|
+
message_id = uuid.uuid4().hex
|
|
1966
|
+
ocpp_action = "ClearCache"
|
|
1967
|
+
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
|
1968
|
+
msg = json.dumps([2, message_id, "ClearCache", {}])
|
|
1969
|
+
async_to_sync(ws.send)(msg)
|
|
1970
|
+
requested_at = timezone.now()
|
|
1971
|
+
store.register_pending_call(
|
|
1972
|
+
message_id,
|
|
1973
|
+
{
|
|
1974
|
+
"action": "ChangeConfiguration",
|
|
1975
|
+
"charger_id": cid,
|
|
1976
|
+
"connector_id": connector_value,
|
|
1977
|
+
"log_key": log_key,
|
|
1978
|
+
"key": key_value,
|
|
1979
|
+
"value": value_text,
|
|
1980
|
+
"requested_at": requested_at,
|
|
1981
|
+
},
|
|
1982
|
+
)
|
|
1983
|
+
timeout_message = str(_("Change configuration request timed out."))
|
|
1984
|
+
store.schedule_call_timeout(
|
|
1985
|
+
message_id,
|
|
1986
|
+
action="ChangeConfiguration",
|
|
1987
|
+
log_key=log_key,
|
|
1988
|
+
message=timeout_message,
|
|
1989
|
+
)
|
|
1990
|
+
if value_included and value_text is not None:
|
|
1991
|
+
change_message = str(
|
|
1992
|
+
_("Requested configuration change for %(key)s to %(value)s")
|
|
1993
|
+
% {"key": key_value, "value": value_text}
|
|
1994
|
+
)
|
|
1995
|
+
else:
|
|
1996
|
+
change_message = str(
|
|
1997
|
+
_("Requested configuration change for %(key)s")
|
|
1998
|
+
% {"key": key_value}
|
|
1999
|
+
)
|
|
2000
|
+
store.add_log(log_key, change_message, log_type="charger")
|
|
1899
2001
|
elif action == "data_transfer":
|
|
1900
2002
|
vendor_id = data.get("vendorId")
|
|
1901
2003
|
if not isinstance(vendor_id, str) or not vendor_id.strip():
|
pages/context_processors.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from utils.sites import get_site
|
|
2
2
|
from django.urls import Resolver404, resolve
|
|
3
|
+
from django.shortcuts import resolve_url
|
|
3
4
|
from django.conf import settings
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from nodes.models import Node
|
|
@@ -148,4 +149,5 @@ def nav_links(request):
|
|
|
148
149
|
"nav_modules": valid_modules,
|
|
149
150
|
"favicon_url": favicon_url,
|
|
150
151
|
"header_references": header_references,
|
|
152
|
+
"login_url": resolve_url(settings.LOGIN_URL),
|
|
151
153
|
}
|
pages/models.py
CHANGED
|
@@ -56,6 +56,11 @@ class Application(Entity):
|
|
|
56
56
|
return self.name
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class Meta:
|
|
60
|
+
verbose_name = _("Application")
|
|
61
|
+
verbose_name_plural = _("Applications")
|
|
62
|
+
|
|
63
|
+
|
|
59
64
|
class ModuleManager(models.Manager):
|
|
60
65
|
def get_by_natural_key(self, role: str, path: str):
|
|
61
66
|
return self.get(node_role__name=role, path=path)
|
|
@@ -222,6 +227,8 @@ class Landing(Entity):
|
|
|
222
227
|
|
|
223
228
|
class Meta:
|
|
224
229
|
unique_together = ("module", "path")
|
|
230
|
+
verbose_name = _("Landing")
|
|
231
|
+
verbose_name_plural = _("Landings")
|
|
225
232
|
|
|
226
233
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
227
234
|
return f"{self.label} ({self.path})"
|
|
@@ -515,6 +522,8 @@ class Favorite(Entity):
|
|
|
515
522
|
class Meta:
|
|
516
523
|
unique_together = ("user", "content_type")
|
|
517
524
|
ordering = ["priority", "pk"]
|
|
525
|
+
verbose_name = _("Favorite")
|
|
526
|
+
verbose_name_plural = _("Favorites")
|
|
518
527
|
|
|
519
528
|
|
|
520
529
|
class UserStory(Lead):
|
pages/tests.py
CHANGED
|
@@ -10,6 +10,7 @@ django.setup()
|
|
|
10
10
|
from django.test import Client, RequestFactory, TestCase, SimpleTestCase, override_settings
|
|
11
11
|
from django.test.utils import CaptureQueriesContext
|
|
12
12
|
from django.urls import reverse
|
|
13
|
+
from django.shortcuts import resolve_url
|
|
13
14
|
from django.templatetags.static import static
|
|
14
15
|
from urllib.parse import quote
|
|
15
16
|
from django.contrib.auth import get_user_model
|
|
@@ -56,6 +57,7 @@ from core import mailer
|
|
|
56
57
|
from core.admin import ProfileAdminMixin
|
|
57
58
|
from core.models import (
|
|
58
59
|
AdminHistory,
|
|
60
|
+
ClientReport,
|
|
59
61
|
InviteLead,
|
|
60
62
|
Package,
|
|
61
63
|
Reference,
|
|
@@ -123,9 +125,26 @@ class LoginViewTests(TestCase):
|
|
|
123
125
|
self.user = User.objects.create_user(username="user", password="pwd")
|
|
124
126
|
Site.objects.update_or_create(id=1, defaults={"name": "Terminal"})
|
|
125
127
|
|
|
128
|
+
def _enable_rfid_scanner(self):
|
|
129
|
+
node, _ = Node.objects.get_or_create(
|
|
130
|
+
mac_address=Node.get_current_mac(),
|
|
131
|
+
defaults={"hostname": "local-node"},
|
|
132
|
+
)
|
|
133
|
+
feature, _ = NodeFeature.objects.get_or_create(
|
|
134
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
135
|
+
)
|
|
136
|
+
NodeFeatureAssignment.objects.get_or_create(node=node, feature=feature)
|
|
137
|
+
return node
|
|
138
|
+
|
|
126
139
|
def test_login_link_in_navbar(self):
|
|
127
140
|
resp = self.client.get(reverse("pages:index"))
|
|
128
|
-
|
|
141
|
+
login_url = resolve_url(settings.LOGIN_URL)
|
|
142
|
+
self.assertContains(resp, f'href="{login_url}"')
|
|
143
|
+
|
|
144
|
+
@override_settings(LOGIN_URL="/staff/login/")
|
|
145
|
+
def test_login_link_uses_configured_login_url(self):
|
|
146
|
+
resp = self.client.get(reverse("pages:index"))
|
|
147
|
+
self.assertContains(resp, 'href="/staff/login/"')
|
|
129
148
|
|
|
130
149
|
def test_login_page_shows_authenticator_toggle(self):
|
|
131
150
|
resp = self.client.get(reverse("pages:login"))
|
|
@@ -201,6 +220,48 @@ class LoginViewTests(TestCase):
|
|
|
201
220
|
)
|
|
202
221
|
self.assertRedirects(resp, "/nodes/list/")
|
|
203
222
|
|
|
223
|
+
def test_login_page_shows_rfid_link_when_feature_enabled(self):
|
|
224
|
+
self._enable_rfid_scanner()
|
|
225
|
+
resp = self.client.get(reverse("pages:login"))
|
|
226
|
+
self.assertContains(resp, reverse("pages:rfid-login"))
|
|
227
|
+
|
|
228
|
+
def test_login_page_detects_rfid_lock_without_mac_address(self):
|
|
229
|
+
Node.objects.all().delete()
|
|
230
|
+
NodeFeature.objects.get_or_create(
|
|
231
|
+
slug="rfid-scanner", defaults={"display": "RFID Scanner"}
|
|
232
|
+
)
|
|
233
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
234
|
+
locks_dir = Path(tempdir) / "locks"
|
|
235
|
+
locks_dir.mkdir()
|
|
236
|
+
(locks_dir / "rfid.lck").touch()
|
|
237
|
+
Node.objects.create(
|
|
238
|
+
hostname="local-node",
|
|
239
|
+
base_path=tempdir,
|
|
240
|
+
current_relation=Node.Relation.SELF,
|
|
241
|
+
mac_address=None,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
resp = self.client.get(reverse("pages:login"))
|
|
245
|
+
|
|
246
|
+
self.assertContains(resp, reverse("pages:rfid-login"))
|
|
247
|
+
|
|
248
|
+
def test_rfid_login_page_requires_feature(self):
|
|
249
|
+
resp = self.client.get(reverse("pages:rfid-login"))
|
|
250
|
+
self.assertEqual(resp.status_code, 404)
|
|
251
|
+
|
|
252
|
+
def test_rfid_login_page_redirects_authenticated_user(self):
|
|
253
|
+
self._enable_rfid_scanner()
|
|
254
|
+
self.client.force_login(self.user)
|
|
255
|
+
resp = self.client.get(reverse("pages:rfid-login"))
|
|
256
|
+
self.assertRedirects(resp, "/")
|
|
257
|
+
|
|
258
|
+
def test_rfid_login_page_includes_scan_url(self):
|
|
259
|
+
self._enable_rfid_scanner()
|
|
260
|
+
resp = self.client.get(reverse("pages:rfid-login"))
|
|
261
|
+
self.assertEqual(resp.status_code, 200)
|
|
262
|
+
self.assertEqual(resp.context["login_api_url"], reverse("rfid-login"))
|
|
263
|
+
self.assertEqual(resp.context["scan_api_url"], reverse("rfid-scan-next"))
|
|
264
|
+
|
|
204
265
|
def test_homepage_excludes_version_banner_for_anonymous(self):
|
|
205
266
|
response = self.client.get(reverse("pages:index"))
|
|
206
267
|
|
|
@@ -726,7 +787,7 @@ class AdminDashboardAppListTests(TestCase):
|
|
|
726
787
|
"hostname": socket.gethostname(),
|
|
727
788
|
"address": socket.gethostbyname(socket.gethostname()),
|
|
728
789
|
"base_path": settings.BASE_DIR,
|
|
729
|
-
"port":
|
|
790
|
+
"port": 8888,
|
|
730
791
|
},
|
|
731
792
|
)
|
|
732
793
|
self.node.features.clear()
|
|
@@ -859,8 +920,11 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
859
920
|
|
|
860
921
|
def _reset_purge_task(self):
|
|
861
922
|
from django_celery_beat.models import PeriodicTask
|
|
923
|
+
from core.celery_utils import periodic_task_name_variants
|
|
862
924
|
|
|
863
|
-
PeriodicTask.objects.filter(
|
|
925
|
+
PeriodicTask.objects.filter(
|
|
926
|
+
name__in=periodic_task_name_variants("pages_purge_landing_leads")
|
|
927
|
+
).delete()
|
|
864
928
|
|
|
865
929
|
def _create_local_node(self):
|
|
866
930
|
node, _ = Node.objects.update_or_create(
|
|
@@ -869,7 +933,7 @@ class ViewHistoryLoggingTests(TestCase):
|
|
|
869
933
|
"hostname": socket.gethostname(),
|
|
870
934
|
"address": "127.0.0.1",
|
|
871
935
|
"base_path": settings.BASE_DIR,
|
|
872
|
-
"port":
|
|
936
|
+
"port": 8888,
|
|
873
937
|
},
|
|
874
938
|
)
|
|
875
939
|
return node
|
|
@@ -1178,7 +1242,7 @@ class LandingLeadAdminTests(TestCase):
|
|
|
1178
1242
|
"hostname": socket.gethostname(),
|
|
1179
1243
|
"address": "127.0.0.1",
|
|
1180
1244
|
"base_path": settings.BASE_DIR,
|
|
1181
|
-
"port":
|
|
1245
|
+
"port": 8888,
|
|
1182
1246
|
},
|
|
1183
1247
|
)
|
|
1184
1248
|
self.node.features.clear()
|
|
@@ -1186,8 +1250,11 @@ class LandingLeadAdminTests(TestCase):
|
|
|
1186
1250
|
|
|
1187
1251
|
def _reset_purge_task(self):
|
|
1188
1252
|
from django_celery_beat.models import PeriodicTask
|
|
1253
|
+
from core.celery_utils import periodic_task_name_variants
|
|
1189
1254
|
|
|
1190
|
-
PeriodicTask.objects.filter(
|
|
1255
|
+
PeriodicTask.objects.filter(
|
|
1256
|
+
name__in=periodic_task_name_variants("pages_purge_landing_leads")
|
|
1257
|
+
).delete()
|
|
1191
1258
|
|
|
1192
1259
|
def test_changelist_warns_without_celery(self):
|
|
1193
1260
|
url = reverse("admin:pages_landinglead_changelist")
|
|
@@ -1403,15 +1470,12 @@ class AdminModelStatusTests(TestCase):
|
|
|
1403
1470
|
|
|
1404
1471
|
Node.objects.create(hostname="testserver", address="127.0.0.1")
|
|
1405
1472
|
|
|
1406
|
-
|
|
1407
|
-
def test_status_dots_render(self, mock_tables):
|
|
1408
|
-
from django.db import connection
|
|
1409
|
-
|
|
1410
|
-
tables = type(connection.introspection).table_names(connection.introspection)
|
|
1411
|
-
mock_tables.return_value = [t for t in tables if t != "pages_module"]
|
|
1473
|
+
def test_status_indicator_removed(self):
|
|
1412
1474
|
resp = self.client.get(reverse("admin:index"))
|
|
1413
|
-
self.
|
|
1414
|
-
|
|
1475
|
+
self.assertNotContains(resp, "class=\"model-status")
|
|
1476
|
+
|
|
1477
|
+
changelist = self.client.get(reverse("admin:pages_application_changelist"))
|
|
1478
|
+
self.assertNotContains(changelist, "class=\"model-status")
|
|
1415
1479
|
|
|
1416
1480
|
|
|
1417
1481
|
class _FakeQuerySet(list):
|
|
@@ -1694,7 +1758,7 @@ class NavAppsTests(TestCase):
|
|
|
1694
1758
|
self.assertContains(resp, "badge rounded-pill")
|
|
1695
1759
|
|
|
1696
1760
|
def test_nav_pill_renders_with_port(self):
|
|
1697
|
-
resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:
|
|
1761
|
+
resp = self.client.get(reverse("pages:index"), HTTP_HOST="127.0.0.1:8888")
|
|
1698
1762
|
self.assertContains(resp, "COOKBOOKS")
|
|
1699
1763
|
|
|
1700
1764
|
def test_nav_pill_uses_menu_field(self):
|
|
@@ -2324,6 +2388,68 @@ class PowerNavTests(TestCase):
|
|
|
2324
2388
|
self.assertEqual(icon_index, -1)
|
|
2325
2389
|
|
|
2326
2390
|
|
|
2391
|
+
class WatchtowerLandingLinkTests(TestCase):
|
|
2392
|
+
def setUp(self):
|
|
2393
|
+
self.client = Client()
|
|
2394
|
+
self.role, _ = NodeRole.objects.get_or_create(name="Watchtower")
|
|
2395
|
+
Node.objects.update_or_create(
|
|
2396
|
+
mac_address=Node.get_current_mac(),
|
|
2397
|
+
defaults={
|
|
2398
|
+
"hostname": "localhost",
|
|
2399
|
+
"address": "127.0.0.1",
|
|
2400
|
+
"role": self.role,
|
|
2401
|
+
},
|
|
2402
|
+
)
|
|
2403
|
+
Site.objects.update_or_create(
|
|
2404
|
+
id=1, defaults={"domain": "testserver", "name": ""}
|
|
2405
|
+
)
|
|
2406
|
+
self.ocpp_app, _ = Application.objects.get_or_create(name="ocpp")
|
|
2407
|
+
self.ocpp_module, _ = Module.objects.get_or_create(
|
|
2408
|
+
node_role=self.role,
|
|
2409
|
+
application=self.ocpp_app,
|
|
2410
|
+
path="/ocpp/",
|
|
2411
|
+
)
|
|
2412
|
+
self.ocpp_module.create_landings()
|
|
2413
|
+
|
|
2414
|
+
def _get_ocpp_module(self, response):
|
|
2415
|
+
for module in response.context["nav_modules"]:
|
|
2416
|
+
if module.path == "/ocpp/":
|
|
2417
|
+
return module
|
|
2418
|
+
return None
|
|
2419
|
+
|
|
2420
|
+
def test_ocpp_landings_present_for_anonymous_users(self):
|
|
2421
|
+
response = self.client.get(reverse("pages:index"))
|
|
2422
|
+
ocpp_module = self._get_ocpp_module(response)
|
|
2423
|
+
self.assertIsNotNone(ocpp_module)
|
|
2424
|
+
landing_by_label = {
|
|
2425
|
+
landing.label: landing for landing in ocpp_module.enabled_landings
|
|
2426
|
+
}
|
|
2427
|
+
expected_landings = {
|
|
2428
|
+
"CPMS Online Dashboard": "/ocpp/cpms/dashboard/",
|
|
2429
|
+
"Charge Point Simulator": "/ocpp/evcs/simulator/",
|
|
2430
|
+
"RFID Tag Validator": "/ocpp/rfid/validator/",
|
|
2431
|
+
}
|
|
2432
|
+
for label, path in expected_landings.items():
|
|
2433
|
+
with self.subTest(label=label):
|
|
2434
|
+
landing = landing_by_label.get(label)
|
|
2435
|
+
self.assertIsNotNone(landing)
|
|
2436
|
+
self.assertEqual(landing.path, path)
|
|
2437
|
+
self.assertTrue(path.startswith("/"))
|
|
2438
|
+
resolve(path)
|
|
2439
|
+
|
|
2440
|
+
def test_simulator_requires_login(self):
|
|
2441
|
+
response = self.client.get(reverse("pages:index"))
|
|
2442
|
+
ocpp_module = self._get_ocpp_module(response)
|
|
2443
|
+
self.assertIsNotNone(ocpp_module)
|
|
2444
|
+
locked_landings = {
|
|
2445
|
+
landing.label: landing
|
|
2446
|
+
for landing in ocpp_module.enabled_landings
|
|
2447
|
+
if getattr(landing, "nav_is_locked", False)
|
|
2448
|
+
}
|
|
2449
|
+
simulator = locked_landings.get("Charge Point Simulator")
|
|
2450
|
+
self.assertIsNotNone(simulator)
|
|
2451
|
+
self.assertTrue(simulator.nav_is_locked)
|
|
2452
|
+
|
|
2327
2453
|
class StaffNavVisibilityTests(TestCase):
|
|
2328
2454
|
def setUp(self):
|
|
2329
2455
|
self.client = Client()
|
|
@@ -3133,7 +3259,7 @@ class FavoriteTests(TestCase):
|
|
|
3133
3259
|
hostname="cached-node",
|
|
3134
3260
|
address="127.0.0.1",
|
|
3135
3261
|
mac_address="AA:BB:CC:DD:EE:FF",
|
|
3136
|
-
port=
|
|
3262
|
+
port=8888,
|
|
3137
3263
|
is_user_data=True,
|
|
3138
3264
|
)
|
|
3139
3265
|
|
|
@@ -3580,7 +3706,7 @@ class UserStorySubmissionTests(TestCase):
|
|
|
3580
3706
|
defaults={
|
|
3581
3707
|
"hostname": "local-node",
|
|
3582
3708
|
"address": "127.0.0.1",
|
|
3583
|
-
"port":
|
|
3709
|
+
"port": 8888,
|
|
3584
3710
|
"public_endpoint": "local-node",
|
|
3585
3711
|
},
|
|
3586
3712
|
)
|
|
@@ -3917,9 +4043,31 @@ class ClientReportLiveUpdateTests(TestCase):
|
|
|
3917
4043
|
|
|
3918
4044
|
def test_client_report_includes_interval(self):
|
|
3919
4045
|
resp = self.client.get(reverse("pages:client-report"))
|
|
3920
|
-
self.assertEqual(resp.
|
|
4046
|
+
self.assertEqual(resp.wsgi_request.live_update_interval, 5)
|
|
3921
4047
|
self.assertContains(resp, "setInterval(() => location.reload()")
|
|
3922
4048
|
|
|
4049
|
+
def test_client_report_download_disables_refresh(self):
|
|
4050
|
+
User = get_user_model()
|
|
4051
|
+
user = User.objects.create_user(username="download-user", password="pwd")
|
|
4052
|
+
report = ClientReport.objects.create(
|
|
4053
|
+
start_date=date(2024, 1, 1),
|
|
4054
|
+
end_date=date(2024, 1, 2),
|
|
4055
|
+
data={},
|
|
4056
|
+
owner=user,
|
|
4057
|
+
disable_emails=True,
|
|
4058
|
+
language="en",
|
|
4059
|
+
title="",
|
|
4060
|
+
)
|
|
4061
|
+
|
|
4062
|
+
self.client.force_login(user)
|
|
4063
|
+
resp = self.client.get(
|
|
4064
|
+
reverse("pages:client-report"), {"download": report.pk}
|
|
4065
|
+
)
|
|
4066
|
+
|
|
4067
|
+
self.assertIsNone(getattr(resp.wsgi_request, "live_update_interval", None))
|
|
4068
|
+
self.assertContains(resp, "report-download-frame")
|
|
4069
|
+
self.assertNotContains(resp, "setInterval(() => location.reload()")
|
|
4070
|
+
|
|
3923
4071
|
|
|
3924
4072
|
class ScreenshotSpecInfrastructureTests(TestCase):
|
|
3925
4073
|
def test_runner_creates_outputs_and_cleans_old_samples(self):
|
pages/urls.py
CHANGED
|
@@ -23,6 +23,7 @@ urlpatterns = [
|
|
|
23
23
|
name="client-report-download",
|
|
24
24
|
),
|
|
25
25
|
path("release-checklist", views.release_checklist, name="release-checklist"),
|
|
26
|
+
path("login/rfid/", views.rfid_login_page, name="rfid-login"),
|
|
26
27
|
path("login/", views.login_view, name="login"),
|
|
27
28
|
path("authenticator/setup/", views.authenticator_setup, name="authenticator-setup"),
|
|
28
29
|
path("request-invite/", views.request_invite, name="request-invite"),
|