arthexis 0.1.22__py3-none-any.whl → 0.1.23__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.

nodes/views.py CHANGED
@@ -43,24 +43,68 @@ PROXY_TOKEN_TIMEOUT = 300
43
43
  PROXY_CACHE_PREFIX = "nodes:proxy-session:"
44
44
 
45
45
 
46
- def _load_signed_node(request, requester_id: str):
46
+ def _load_signed_node(
47
+ request,
48
+ requester_id: str,
49
+ *,
50
+ mac_address: str | None = None,
51
+ public_key: str | None = None,
52
+ ):
47
53
  signature = request.headers.get("X-Signature")
48
54
  if not signature:
49
55
  return None, JsonResponse({"detail": "signature required"}, status=403)
50
- node = Node.objects.filter(uuid=requester_id).first()
51
- if not node or not node.public_key:
52
- return None, JsonResponse({"detail": "unknown requester"}, status=403)
53
56
  try:
54
- public_key = serialization.load_pem_public_key(node.public_key.encode())
55
- public_key.verify(
56
- base64.b64decode(signature),
57
- request.body,
58
- padding.PKCS1v15(),
59
- hashes.SHA256(),
60
- )
57
+ signature_bytes = base64.b64decode(signature)
61
58
  except Exception:
62
59
  return None, JsonResponse({"detail": "invalid signature"}, status=403)
63
- return node, None
60
+
61
+ candidates: list[Node] = []
62
+ seen: set[int] = set()
63
+
64
+ lookup_values: list[tuple[str, str]] = []
65
+ if requester_id:
66
+ lookup_values.append(("uuid", requester_id))
67
+ if mac_address:
68
+ lookup_values.append(("mac_address__iexact", mac_address))
69
+ if public_key:
70
+ lookup_values.append(("public_key", public_key))
71
+
72
+ for field, value in lookup_values:
73
+ node = Node.objects.filter(**{field: value}).first()
74
+ if not node or not node.public_key:
75
+ continue
76
+ if node.pk is not None and node.pk in seen:
77
+ continue
78
+ if node.pk is not None:
79
+ seen.add(node.pk)
80
+ candidates.append(node)
81
+
82
+ if not candidates:
83
+ return None, JsonResponse({"detail": "unknown requester"}, status=403)
84
+
85
+ for node in candidates:
86
+ try:
87
+ loaded_key = serialization.load_pem_public_key(node.public_key.encode())
88
+ loaded_key.verify(
89
+ signature_bytes,
90
+ request.body,
91
+ padding.PKCS1v15(),
92
+ hashes.SHA256(),
93
+ )
94
+ except Exception:
95
+ continue
96
+ return node, None
97
+
98
+ return None, JsonResponse({"detail": "invalid signature"}, status=403)
99
+
100
+
101
+ def _clean_requester_hint(value, *, strip: bool = True) -> str | None:
102
+ if not isinstance(value, str):
103
+ return None
104
+ cleaned = value.strip() if strip else value
105
+ if not cleaned:
106
+ return None
107
+ return cleaned
64
108
 
65
109
 
66
110
  def _sanitize_proxy_target(target: str | None, request) -> str:
@@ -589,26 +633,21 @@ def export_rfids(request):
589
633
  return JsonResponse({"detail": "invalid json"}, status=400)
590
634
 
591
635
  requester = payload.get("requester")
592
- signature = request.headers.get("X-Signature")
593
636
  if not requester:
594
637
  return JsonResponse({"detail": "requester required"}, status=400)
595
- if not signature:
596
- return JsonResponse({"detail": "signature required"}, status=403)
597
638
 
598
- node = Node.objects.filter(uuid=requester).first()
599
- if not node or not node.public_key:
600
- return JsonResponse({"detail": "unknown requester"}, status=403)
601
-
602
- try:
603
- public_key = serialization.load_pem_public_key(node.public_key.encode())
604
- public_key.verify(
605
- base64.b64decode(signature),
606
- request.body,
607
- padding.PKCS1v15(),
608
- hashes.SHA256(),
609
- )
610
- except Exception:
611
- return JsonResponse({"detail": "invalid signature"}, status=403)
639
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
640
+ requester_public_key = _clean_requester_hint(
641
+ payload.get("requester_public_key"), strip=False
642
+ )
643
+ node, error_response = _load_signed_node(
644
+ request,
645
+ requester,
646
+ mac_address=requester_mac,
647
+ public_key=requester_public_key,
648
+ )
649
+ if error_response is not None:
650
+ return error_response
612
651
 
613
652
  tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
614
653
 
@@ -628,26 +667,21 @@ def import_rfids(request):
628
667
  return JsonResponse({"detail": "invalid json"}, status=400)
629
668
 
630
669
  requester = payload.get("requester")
631
- signature = request.headers.get("X-Signature")
632
670
  if not requester:
633
671
  return JsonResponse({"detail": "requester required"}, status=400)
634
- if not signature:
635
- return JsonResponse({"detail": "signature required"}, status=403)
636
-
637
- node = Node.objects.filter(uuid=requester).first()
638
- if not node or not node.public_key:
639
- return JsonResponse({"detail": "unknown requester"}, status=403)
640
672
 
641
- try:
642
- public_key = serialization.load_pem_public_key(node.public_key.encode())
643
- public_key.verify(
644
- base64.b64decode(signature),
645
- request.body,
646
- padding.PKCS1v15(),
647
- hashes.SHA256(),
648
- )
649
- except Exception:
650
- return JsonResponse({"detail": "invalid signature"}, status=403)
673
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
674
+ requester_public_key = _clean_requester_hint(
675
+ payload.get("requester_public_key"), strip=False
676
+ )
677
+ node, error_response = _load_signed_node(
678
+ request,
679
+ requester,
680
+ mac_address=requester_mac,
681
+ public_key=requester_public_key,
682
+ )
683
+ if error_response is not None:
684
+ return error_response
651
685
 
652
686
  rfids = payload.get("rfids", [])
653
687
  if not isinstance(rfids, list):
@@ -704,7 +738,16 @@ def proxy_session(request):
704
738
  if not requester:
705
739
  return JsonResponse({"detail": "requester required"}, status=400)
706
740
 
707
- node, error_response = _load_signed_node(request, requester)
741
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
742
+ requester_public_key = _clean_requester_hint(
743
+ payload.get("requester_public_key"), strip=False
744
+ )
745
+ node, error_response = _load_signed_node(
746
+ request,
747
+ requester,
748
+ mac_address=requester_mac,
749
+ public_key=requester_public_key,
750
+ )
708
751
  if error_response is not None:
709
752
  return error_response
710
753
 
@@ -844,7 +887,16 @@ def proxy_execute(request):
844
887
  if not requester:
845
888
  return JsonResponse({"detail": "requester required"}, status=400)
846
889
 
847
- node, error_response = _load_signed_node(request, requester)
890
+ requester_mac = _clean_requester_hint(payload.get("requester_mac"))
891
+ requester_public_key = _clean_requester_hint(
892
+ payload.get("requester_public_key"), strip=False
893
+ )
894
+ node, error_response = _load_signed_node(
895
+ request,
896
+ requester,
897
+ mac_address=requester_mac,
898
+ public_key=requester_public_key,
899
+ )
848
900
  if error_response is not None:
849
901
  return error_response
850
902
 
ocpp/admin.py CHANGED
@@ -208,7 +208,7 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
208
208
  @admin.register(Location)
209
209
  class LocationAdmin(EntityModelAdmin):
210
210
  form = LocationAdminForm
211
- list_display = ("name", "latitude", "longitude")
211
+ list_display = ("name", "zone", "contract_type", "latitude", "longitude")
212
212
  change_form_template = "admin/ocpp/location/change_form.html"
213
213
 
214
214
 
@@ -771,6 +771,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
771
771
  level=messages.ERROR,
772
772
  )
773
773
  continue
774
+ tx_obj = store.get_transaction(charger.charger_id, connector_value)
775
+ if tx_obj is not None:
776
+ self.message_user(
777
+ request,
778
+ (
779
+ f"{charger}: reset skipped because a session is active; "
780
+ "stop the session first."
781
+ ),
782
+ level=messages.WARNING,
783
+ )
784
+ continue
774
785
  message_id = uuid.uuid4().hex
775
786
  msg = json.dumps([
776
787
  2,
ocpp/models.py CHANGED
@@ -15,6 +15,7 @@ from nodes.models import Node
15
15
 
16
16
  from core.models import (
17
17
  EnergyAccount,
18
+ EnergyTariff,
18
19
  Reference,
19
20
  RFID as CoreRFID,
20
21
  ElectricVehicle as CoreElectricVehicle,
@@ -35,6 +36,22 @@ class Location(Entity):
35
36
  longitude = models.DecimalField(
36
37
  max_digits=9, decimal_places=6, null=True, blank=True
37
38
  )
39
+ zone = models.CharField(
40
+ max_length=3,
41
+ choices=EnergyTariff.Zone.choices,
42
+ blank=True,
43
+ null=True,
44
+ help_text=_("CFE climate zone used to select matching energy tariffs."),
45
+ )
46
+ contract_type = models.CharField(
47
+ max_length=16,
48
+ choices=EnergyTariff.ContractType.choices,
49
+ blank=True,
50
+ null=True,
51
+ help_text=_(
52
+ "CFE service contract type required to match energy tariff pricing."
53
+ ),
54
+ )
38
55
 
39
56
  def __str__(self) -> str: # pragma: no cover - simple representation
40
57
  return self.name
@@ -481,11 +498,17 @@ class Charger(Entity):
481
498
  ref_value = self._full_url()
482
499
  if url_targets_local_loopback(ref_value):
483
500
  return
484
- if not self.reference or self.reference.value != ref_value:
501
+ if not self.reference:
485
502
  self.reference = Reference.objects.create(
486
503
  value=ref_value, alt_text=self.charger_id
487
504
  )
488
505
  super().save(update_fields=["reference"])
506
+ elif self.reference.value != ref_value:
507
+ Reference.objects.filter(pk=self.reference_id).update(
508
+ value=ref_value, alt_text=self.charger_id
509
+ )
510
+ self.reference.value = ref_value
511
+ self.reference.alt_text = self.charger_id
489
512
 
490
513
  def refresh_manager_node(self, node: Node | None = None) -> Node | None:
491
514
  """Ensure ``manager_node`` matches the provided or local node."""
@@ -783,7 +806,11 @@ class Transaction(Entity):
783
806
  def vehicle_identifier(self) -> str:
784
807
  """Return the preferred vehicle identifier for this transaction."""
785
808
 
786
- return (self.vid or self.vin or "").strip()
809
+ vid = (self.vid or "").strip()
810
+ if vid:
811
+ return vid
812
+
813
+ return (self.vin or "").strip()
787
814
 
788
815
  @property
789
816
  def vehicle_identifier_source(self) -> str:
ocpp/tests.py CHANGED
@@ -177,6 +177,36 @@ class DispatchActionTests(TestCase):
177
177
  self.assertEqual(metadata.get("trigger_target"), "BootNotification")
178
178
  self.assertEqual(metadata.get("log_key"), log_key)
179
179
 
180
+ def test_reset_rejected_when_transaction_active(self):
181
+ charger = Charger.objects.create(charger_id="RESETBLOCK")
182
+ dummy = DummyWebSocket()
183
+ connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
184
+ self.addCleanup(lambda: store.connections.pop(connection_key, None))
185
+ tx_obj = Transaction.objects.create(
186
+ charger=charger,
187
+ connector_id=charger.connector_id,
188
+ start_time=timezone.now(),
189
+ )
190
+ tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
191
+ self.addCleanup(lambda: store.transactions.pop(tx_key, None))
192
+
193
+ request = self.factory.post(
194
+ "/chargers/RESETBLOCK/action/",
195
+ data=json.dumps({"action": "reset"}),
196
+ content_type="application/json",
197
+ )
198
+ request.user = SimpleNamespace(
199
+ is_authenticated=True,
200
+ is_superuser=True,
201
+ is_staff=True,
202
+ )
203
+
204
+ response = dispatch_action(request, charger.charger_id)
205
+ self.assertEqual(response.status_code, 409)
206
+ payload = json.loads(response.content.decode("utf-8"))
207
+ self.assertIn("stop the session first", payload.get("detail", "").lower())
208
+ self.assertFalse(dummy.sent)
209
+
180
210
  class ChargerFixtureTests(TestCase):
181
211
  fixtures = [
182
212
  p.name
@@ -218,6 +248,62 @@ class ChargerFixtureTests(TestCase):
218
248
  self.assertEqual(cp2.name, "Simulator #2")
219
249
 
220
250
 
251
+ class ChargerRefreshManagerNodeTests(TestCase):
252
+ @classmethod
253
+ def setUpTestData(cls):
254
+ local = Node.objects.create(
255
+ hostname="local-node",
256
+ address="127.0.0.1",
257
+ port=8000,
258
+ mac_address="aa:bb:cc:dd:ee:ff",
259
+ current_relation=Node.Relation.SELF,
260
+ )
261
+ Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
262
+ cls.local_node = Node.objects.get(pk=local.pk)
263
+
264
+ def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
265
+ charger = Charger(charger_id="UNSAVED")
266
+
267
+ with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
268
+ result = charger.refresh_manager_node()
269
+
270
+ self.assertEqual(result, self.local_node)
271
+ self.assertEqual(charger.manager_node, self.local_node)
272
+
273
+ def test_refresh_manager_node_updates_persisted_charger(self):
274
+ remote = Node.objects.create(
275
+ hostname="remote-node",
276
+ address="10.0.0.1",
277
+ port=9000,
278
+ mac_address="11:22:33:44:55:66",
279
+ )
280
+ charger = Charger.objects.create(
281
+ charger_id="PERSISTED",
282
+ manager_node=remote,
283
+ )
284
+
285
+ charger.refresh_manager_node(node=self.local_node)
286
+
287
+ self.assertEqual(charger.manager_node, self.local_node)
288
+ charger.refresh_from_db()
289
+ self.assertEqual(charger.manager_node, self.local_node)
290
+
291
+ def test_refresh_manager_node_handles_missing_local_node(self):
292
+ remote = Node.objects.create(
293
+ hostname="existing-manager",
294
+ address="10.0.0.2",
295
+ port=9001,
296
+ mac_address="22:33:44:55:66:77",
297
+ )
298
+ charger = Charger(charger_id="NOLOCAL", manager_node=remote)
299
+
300
+ with patch.object(Node, "get_local", return_value=None):
301
+ result = charger.refresh_manager_node()
302
+
303
+ self.assertIsNone(result)
304
+ self.assertEqual(charger.manager_node, remote)
305
+
306
+
221
307
  class ChargerUrlFallbackTests(TestCase):
222
308
  @override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
223
309
  def test_reference_created_when_site_missing(self):
@@ -2320,6 +2406,43 @@ class ChargerAdminTests(TestCase):
2320
2406
  store.pop_connection(charger.charger_id, charger.connector_id)
2321
2407
  store.clear_pending_calls(charger.charger_id)
2322
2408
 
2409
+ def test_reset_charger_action_skips_when_transaction_active(self):
2410
+ charger = Charger.objects.create(charger_id="RESETADMIN")
2411
+
2412
+ class DummyConnection:
2413
+ def __init__(self):
2414
+ self.sent: list[str] = []
2415
+
2416
+ async def send(self, message):
2417
+ self.sent.append(message)
2418
+
2419
+ ws = DummyConnection()
2420
+ store.set_connection(charger.charger_id, charger.connector_id, ws)
2421
+ tx_obj = Transaction.objects.create(
2422
+ charger=charger,
2423
+ connector_id=charger.connector_id,
2424
+ start_time=timezone.now(),
2425
+ )
2426
+ store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
2427
+ try:
2428
+ url = reverse("admin:ocpp_charger_changelist")
2429
+ response = self.client.post(
2430
+ url,
2431
+ {
2432
+ "action": "reset_chargers",
2433
+ "index": 0,
2434
+ "select_across": 0,
2435
+ "_selected_action": [charger.pk],
2436
+ },
2437
+ follow=True,
2438
+ )
2439
+ self.assertEqual(response.status_code, 200)
2440
+ self.assertFalse(ws.sent)
2441
+ self.assertContains(response, "stop the session first")
2442
+ finally:
2443
+ store.pop_connection(charger.charger_id, charger.connector_id)
2444
+ store.pop_transaction(charger.charger_id, charger.connector_id)
2445
+
2323
2446
  def test_admin_log_view_displays_entries(self):
2324
2447
  charger = Charger.objects.create(charger_id="LOG2")
2325
2448
  log_id = store.identity_key(charger.charger_id, charger.connector_id)
ocpp/views.py CHANGED
@@ -1609,6 +1609,15 @@ def charger_session_search(request, cid, connector=None):
1609
1609
  transactions = qs.order_by("-start_time")
1610
1610
  except ValueError:
1611
1611
  transactions = []
1612
+ if transactions is not None:
1613
+ transactions = list(transactions)
1614
+ rfid_cache: dict[str, dict[str, str | None]] = {}
1615
+ for tx in transactions:
1616
+ details = _transaction_rfid_details(tx, cache=rfid_cache)
1617
+ label_value = None
1618
+ if details:
1619
+ label_value = str(details.get("label") or "").strip() or None
1620
+ tx.rfid_label = label_value
1612
1621
  overview = _connector_overview(charger, request.user)
1613
1622
  connector_links = [
1614
1623
  {
@@ -1734,7 +1743,7 @@ def charger_log_page(request, cid, connector=None):
1734
1743
  @csrf_exempt
1735
1744
  @api_login_required
1736
1745
  def dispatch_action(request, cid, connector=None):
1737
- connector_value, _ = _normalize_connector_slug(connector)
1746
+ connector_value, _normalized_slug = _normalize_connector_slug(connector)
1738
1747
  log_key = store.identity_key(cid, connector_value)
1739
1748
  if connector_value is None:
1740
1749
  charger_obj = (
@@ -1750,11 +1759,11 @@ def dispatch_action(request, cid, connector=None):
1750
1759
  )
1751
1760
  if charger_obj is None:
1752
1761
  if connector_value is None:
1753
- charger_obj, _ = Charger.objects.get_or_create(
1762
+ charger_obj, _created = Charger.objects.get_or_create(
1754
1763
  charger_id=cid, connector_id=None
1755
1764
  )
1756
1765
  else:
1757
- charger_obj, _ = Charger.objects.get_or_create(
1766
+ charger_obj, _created = Charger.objects.get_or_create(
1758
1767
  charger_id=cid, connector_id=connector_value
1759
1768
  )
1760
1769
 
@@ -1925,6 +1934,13 @@ def dispatch_action(request, cid, connector=None):
1925
1934
  },
1926
1935
  )
1927
1936
  elif action == "reset":
1937
+ tx_obj = store.get_transaction(cid, connector_value)
1938
+ if tx_obj is not None:
1939
+ detail = _(
1940
+ "Reset is blocked while a charging session is active. "
1941
+ "Stop the session first."
1942
+ )
1943
+ return JsonResponse({"detail": detail}, status=409)
1928
1944
  message_id = uuid.uuid4().hex
1929
1945
  ocpp_action = "Reset"
1930
1946
  expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
pages/tests.py CHANGED
@@ -109,6 +109,7 @@ from nodes.models import (
109
109
  NodeRole,
110
110
  NodeFeature,
111
111
  NodeFeatureAssignment,
112
+ NetMessage,
112
113
  )
113
114
  from django.contrib.auth.models import AnonymousUser
114
115
 
@@ -721,18 +722,36 @@ class AdminDashboardAppListTests(TestCase):
721
722
 
722
723
  def test_horologia_hidden_without_celery_feature(self):
723
724
  resp = self.client.get(reverse("admin:index"))
724
- self.assertNotContains(resp, "5. Horologia MODELS")
725
+ self.assertNotContains(resp, "5. Horologia</a>")
725
726
 
726
727
  def test_horologia_visible_with_celery_feature(self):
727
728
  feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
728
729
  NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
729
730
  resp = self.client.get(reverse("admin:index"))
730
- self.assertContains(resp, "5. Horologia MODELS")
731
+ self.assertContains(resp, "5. Horologia</a>")
731
732
 
732
733
  def test_horologia_visible_with_celery_lock(self):
733
734
  self.celery_lock.write_text("")
734
735
  resp = self.client.get(reverse("admin:index"))
735
- self.assertContains(resp, "5. Horologia MODELS")
736
+ self.assertContains(resp, "5. Horologia</a>")
737
+
738
+ def test_dashboard_shows_last_net_message(self):
739
+ NetMessage.objects.all().delete()
740
+ NetMessage.objects.create(subject="Older", body="First body")
741
+ NetMessage.objects.create(subject="Latest", body="Signal ready")
742
+
743
+ resp = self.client.get(reverse("admin:index"))
744
+
745
+ self.assertContains(resp, gettext("Net message"))
746
+ self.assertContains(resp, "Latest — Signal ready")
747
+ self.assertNotContains(resp, gettext("No net messages available"))
748
+
749
+ def test_dashboard_shows_placeholder_without_net_message(self):
750
+ NetMessage.objects.all().delete()
751
+
752
+ resp = self.client.get(reverse("admin:index"))
753
+
754
+ self.assertContains(resp, gettext("No net messages available"))
736
755
 
737
756
  class AdminSidebarTests(TestCase):
738
757
  def setUp(self):
@@ -2083,7 +2102,7 @@ class ControlNavTests(TestCase):
2083
2102
 
2084
2103
  def test_readme_pill_visible(self):
2085
2104
  resp = self.client.get(reverse("pages:readme"))
2086
- self.assertContains(resp, 'href="/read/"')
2105
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2087
2106
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
2088
2107
 
2089
2108
  def test_cookbook_pill_has_no_dropdown(self):
@@ -2099,7 +2118,7 @@ class ControlNavTests(TestCase):
2099
2118
 
2100
2119
  self.assertContains(
2101
2120
  resp,
2102
- '<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
2121
+ '<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
2103
2122
  html=True,
2104
2123
  )
2105
2124
  self.assertNotContains(resp, 'dropdown-item" href="/man/"')
@@ -2205,7 +2224,7 @@ class SatelliteNavTests(TestCase):
2205
2224
 
2206
2225
  def test_readme_pill_visible(self):
2207
2226
  resp = self.client.get(reverse("pages:readme"))
2208
- self.assertContains(resp, 'href="/read/"')
2227
+ self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
2209
2228
  self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
2210
2229
 
2211
2230
 
pages/urls.py CHANGED
@@ -17,6 +17,11 @@ urlpatterns = [
17
17
  path("sitemap.xml", views.sitemap, name="pages-sitemap"),
18
18
  path("release/", views.release_admin_redirect, name="release-admin"),
19
19
  path("client-report/", views.client_report, name="client-report"),
20
+ path(
21
+ "client-report/download/<int:report_id>/",
22
+ views.client_report_download,
23
+ name="client-report-download",
24
+ ),
20
25
  path("release-checklist", views.release_checklist, name="release-checklist"),
21
26
  path("login/", views.login_view, name="login"),
22
27
  path("authenticator/setup/", views.authenticator_setup, name="authenticator-setup"),