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.

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 Transaction, Charger, DataTransferMessage, RFID
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
- remaining = {k: v for k, v in payload_dict.items() if k != "status"}
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 key in keys:
409
- for entry in store.get_logs(key, log_type="charger"):
410
- if entry in seen_entries:
411
- continue
412
- seen_entries.add(entry)
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
- event_time = log_timestamp
425
- status_bucket: str | None = None
458
+ event_time = log_timestamp
459
+ status_bucket: str | None = None
426
460
 
427
- if message.startswith("StatusNotification processed:"):
428
- payload_text = message.split(":", 1)[1].strip()
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
- payload = json.loads(payload_text)
431
- except json.JSONDecodeError:
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
- target_id = payload.get("connectorId")
434
- if connector_id is not None:
435
- try:
436
- normalized_target = int(target_id)
437
- except (TypeError, ValueError):
438
- normalized_target = None
439
- if normalized_target not in {connector_id, None}:
440
- continue
441
- raw_status = payload.get("status")
442
- status_bucket = _normalize_timeline_status(
443
- raw_status if isinstance(raw_status, str) else None
444
- )
445
- payload_timestamp = payload.get("timestamp")
446
- if isinstance(payload_timestamp, str):
447
- parsed = parse_datetime(payload_timestamp)
448
- if parsed is not None:
449
- if timezone.is_naive(parsed):
450
- parsed = timezone.make_aware(parsed, timezone=dt_timezone.utc)
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
- if event_time < window_start:
461
- if (
462
- latest_before_window is None
463
- or event_time > latest_before_window[0]
464
- ):
465
- latest_before_window = (event_time, status_bucket)
466
- continue
467
- if event_time > window_end:
468
- continue
469
- events.append((event_time, status_bucket))
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
- log_entries = log_entries_all
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
- log_entries = log_entries[-limit_value:]
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():
@@ -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
- self.assertContains(resp, 'href="/login/"')
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": 8000,
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(name="pages_purge_landing_leads").delete()
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": 8000,
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": 8000,
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(name="pages_purge_landing_leads").delete()
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
- @patch("pages.templatetags.admin_extras.connection.introspection.table_names")
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.assertContains(resp, 'class="model-status ok"')
1414
- self.assertContains(resp, 'class="model-status missing"', count=1)
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:8000")
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=8000,
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": 8000,
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.context["request"].live_update_interval, 5)
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"),