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

core/tasks.py CHANGED
@@ -405,3 +405,28 @@ def run_client_report_schedule(schedule_id: int) -> None:
405
405
  except Exception:
406
406
  logger.exception("ClientReportSchedule %s failed", schedule_id)
407
407
  raise
408
+
409
+
410
+ @shared_task
411
+ def ensure_recurring_client_reports() -> None:
412
+ """Ensure scheduled consumer reports run for the current period."""
413
+
414
+ from core.models import ClientReportSchedule
415
+
416
+ reference = timezone.localdate()
417
+ schedules = ClientReportSchedule.objects.filter(
418
+ periodicity__in=[
419
+ ClientReportSchedule.PERIODICITY_DAILY,
420
+ ClientReportSchedule.PERIODICITY_WEEKLY,
421
+ ClientReportSchedule.PERIODICITY_MONTHLY,
422
+ ]
423
+ ).prefetch_related("chargers")
424
+
425
+ for schedule in schedules:
426
+ try:
427
+ schedule.generate_missing_reports(reference=reference)
428
+ except Exception:
429
+ logger.exception(
430
+ "Automatic consumer report generation failed for schedule %s",
431
+ schedule.pk,
432
+ )
nodes/admin.py CHANGED
@@ -60,6 +60,7 @@ from .models import (
60
60
  )
61
61
  from . import dns as dns_utils
62
62
  from core.models import RFID
63
+ from ocpp.models import Charger, Location
63
64
  from core.user_data import EntityModelAdmin
64
65
 
65
66
 
@@ -237,7 +238,6 @@ class NodeAdmin(EntityModelAdmin):
237
238
  "relation",
238
239
  "last_seen",
239
240
  "visit_link",
240
- "proxy_link",
241
241
  )
242
242
  search_fields = ("hostname", "address", "mac_address")
243
243
  change_list_template = "admin/nodes/node/change_list.html"
@@ -287,6 +287,7 @@ class NodeAdmin(EntityModelAdmin):
287
287
  "register_visitor",
288
288
  "run_task",
289
289
  "take_screenshots",
290
+ "discover_charge_points",
290
291
  "import_rfids_from_selected",
291
292
  "export_rfids_to_selected",
292
293
  ]
@@ -296,16 +297,6 @@ class NodeAdmin(EntityModelAdmin):
296
297
  def relation(self, obj):
297
298
  return obj.get_current_relation_display()
298
299
 
299
- @admin.display(description=_("Proxy"))
300
- def proxy_link(self, obj):
301
- if not obj or obj.is_local:
302
- return ""
303
- try:
304
- url = reverse("admin:nodes_node_proxy", args=[obj.pk])
305
- except NoReverseMatch:
306
- return ""
307
- return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
308
-
309
300
  @admin.display(description=_("Visit"))
310
301
  def visit_link(self, obj):
311
302
  if not obj:
@@ -372,11 +363,6 @@ class NodeAdmin(EntityModelAdmin):
372
363
  self.admin_site.admin_view(self.update_selected_progress),
373
364
  name="nodes_node_update_selected_progress",
374
365
  ),
375
- path(
376
- "<int:node_id>/proxy/",
377
- self.admin_site.admin_view(self.proxy_node),
378
- name="nodes_node_proxy",
379
- ),
380
366
  ]
381
367
  return custom + urls
382
368
 
@@ -409,162 +395,6 @@ class NodeAdmin(EntityModelAdmin):
409
395
  )
410
396
  return response
411
397
 
412
- def _load_local_private_key(self, node):
413
- security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
414
- priv_path = security_dir / f"{node.public_endpoint}"
415
- if not priv_path.exists():
416
- return None, _("Local node private key not found.")
417
- try:
418
- return (
419
- serialization.load_pem_private_key(
420
- priv_path.read_bytes(), password=None
421
- ),
422
- "",
423
- )
424
- except Exception as exc: # pragma: no cover - unexpected errors
425
- return None, str(exc)
426
-
427
- def _build_proxy_payload(self, request, local_node):
428
- user = request.user
429
- payload = {
430
- "requester": str(local_node.uuid),
431
- "user": {
432
- "username": user.get_username(),
433
- "email": user.email or "",
434
- "first_name": user.first_name or "",
435
- "last_name": user.last_name or "",
436
- "is_staff": user.is_staff,
437
- "is_superuser": user.is_superuser,
438
- "groups": list(user.groups.values_list("name", flat=True)),
439
- "permissions": sorted(user.get_all_permissions()),
440
- },
441
- "target": reverse("admin:index"),
442
- }
443
- mac_address = str(local_node.mac_address or "").strip()
444
- if mac_address:
445
- payload["requester_mac"] = mac_address
446
- public_key = local_node.public_key
447
- if public_key:
448
- payload["requester_public_key"] = public_key
449
- return payload
450
-
451
- def _start_proxy_session(self, request, node):
452
- if node.is_local:
453
- return {"ok": False, "message": _("Local node cannot be proxied.")}
454
-
455
- local_node = Node.get_local()
456
- if local_node is None:
457
- try:
458
- local_node, _ = Node.register_current()
459
- except Exception as exc: # pragma: no cover - unexpected errors
460
- return {"ok": False, "message": str(exc)}
461
-
462
- private_key, error = self._load_local_private_key(local_node)
463
- if private_key is None:
464
- return {"ok": False, "message": error}
465
-
466
- payload = self._build_proxy_payload(request, local_node)
467
- body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
468
- try:
469
- signature = private_key.sign(
470
- body.encode(),
471
- padding.PKCS1v15(),
472
- hashes.SHA256(),
473
- )
474
- except Exception as exc: # pragma: no cover - unexpected errors
475
- return {"ok": False, "message": str(exc)}
476
-
477
- headers = {
478
- "Content-Type": "application/json",
479
- "X-Signature": base64.b64encode(signature).decode(),
480
- }
481
-
482
- last_error = ""
483
- redirect_codes = {301, 302, 303, 307, 308}
484
-
485
- for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
486
- candidate_url = url
487
- redirects_followed = 0
488
- success = False
489
-
490
- while True:
491
- try:
492
- response = requests.post(
493
- candidate_url,
494
- data=body,
495
- headers=headers,
496
- timeout=5,
497
- allow_redirects=False,
498
- )
499
- except RequestException as exc:
500
- last_error = str(exc)
501
- break
502
-
503
- if response.status_code in redirect_codes:
504
- location = response.headers.get("Location")
505
- if not location:
506
- last_error = f"{response.status_code} redirect missing Location header"
507
- break
508
-
509
- redirects_followed += 1
510
- if redirects_followed > 3:
511
- last_error = "Too many redirects"
512
- break
513
-
514
- candidate_url = urljoin(candidate_url, location)
515
- continue
516
-
517
- if not response.ok:
518
- last_error = f"{response.status_code} {response.text}"
519
- break
520
-
521
- try:
522
- data = response.json()
523
- except ValueError:
524
- last_error = "Invalid JSON response"
525
- break
526
-
527
- login_url = data.get("login_url")
528
- if not login_url:
529
- last_error = "login_url missing"
530
- break
531
-
532
- success = True
533
- break
534
-
535
- if success:
536
- return {
537
- "ok": True,
538
- "login_url": login_url,
539
- "expires": data.get("expires"),
540
- }
541
-
542
- return {
543
- "ok": False,
544
- "message": last_error or "Unable to initiate proxy.",
545
- }
546
-
547
- def proxy_node(self, request, node_id):
548
- node = self.get_queryset(request).filter(pk=node_id).first()
549
- if not node:
550
- raise Http404
551
- if not self.has_view_permission(request):
552
- raise PermissionDenied
553
- result = self._start_proxy_session(request, node)
554
- if not result.get("ok"):
555
- message = result.get("message") or _("Unable to proxy node.")
556
- self.message_user(request, message, messages.ERROR)
557
- return redirect("admin:nodes_node_changelist")
558
-
559
- context = {
560
- **self.admin_site.each_context(request),
561
- "opts": self.model._meta,
562
- "node": node,
563
- "frame_url": result.get("login_url"),
564
- "expires": result.get("expires"),
565
- }
566
- return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
567
-
568
398
  @admin.action(description="Register Visitor")
569
399
  def register_visitor(self, request, queryset=None):
570
400
  return self.register_visitor_view(request)
@@ -1230,6 +1060,156 @@ class NodeAdmin(EntityModelAdmin):
1230
1060
 
1231
1061
  return self._render_rfid_sync(request, "export", results)
1232
1062
 
1063
+ @admin.action(description=_("Discover Charge Points"))
1064
+ def discover_charge_points(self, request, queryset):
1065
+ local_node, private_key, error = self._load_local_node_credentials()
1066
+ if error:
1067
+ self.message_user(request, error, level=messages.ERROR)
1068
+ return
1069
+
1070
+ nodes = [node for node in queryset if not local_node.pk or node.pk != local_node.pk]
1071
+ if not nodes:
1072
+ self.message_user(request, _("No remote nodes selected."), level=messages.WARNING)
1073
+ return
1074
+
1075
+ payload = json.dumps(
1076
+ {"requester": str(local_node.uuid)},
1077
+ separators=(",", ":"),
1078
+ sort_keys=True,
1079
+ )
1080
+ signature = self._sign_payload(private_key, payload)
1081
+ headers = {
1082
+ "Content-Type": "application/json",
1083
+ "X-Signature": signature,
1084
+ }
1085
+
1086
+ created = 0
1087
+ updated = 0
1088
+ errors: list[str] = []
1089
+
1090
+ for node in nodes:
1091
+ url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
1092
+ try:
1093
+ response = requests.post(url, data=payload, headers=headers, timeout=5)
1094
+ except RequestException as exc:
1095
+ errors.append(f"{node}: {exc}")
1096
+ continue
1097
+
1098
+ if response.status_code != 200:
1099
+ errors.append(f"{node}: {response.status_code} {response.text}")
1100
+ continue
1101
+
1102
+ try:
1103
+ data = response.json()
1104
+ except ValueError:
1105
+ errors.append(f"{node}: invalid JSON response")
1106
+ continue
1107
+
1108
+ for entry in data.get("chargers", []):
1109
+ applied = self._apply_remote_charger_payload(node, entry)
1110
+ if applied == "created":
1111
+ created += 1
1112
+ elif applied == "updated":
1113
+ updated += 1
1114
+
1115
+ if created or updated:
1116
+ summary = _("Imported %(created)s new and %(updated)s existing charge point(s).") % {
1117
+ "created": created,
1118
+ "updated": updated,
1119
+ }
1120
+ self.message_user(request, summary, level=messages.SUCCESS)
1121
+ if errors:
1122
+ for error in errors:
1123
+ self.message_user(request, error, level=messages.ERROR)
1124
+
1125
+ def _apply_remote_charger_payload(self, node, payload: Mapping) -> str | None:
1126
+ serial = Charger.normalize_serial(payload.get("charger_id"))
1127
+ if not serial or Charger.is_placeholder_serial(serial):
1128
+ return None
1129
+
1130
+ connector_value = payload.get("connector_id")
1131
+ if connector_value in ("", None):
1132
+ connector_value = None
1133
+ elif isinstance(connector_value, str):
1134
+ try:
1135
+ connector_value = int(connector_value)
1136
+ except ValueError:
1137
+ connector_value = None
1138
+
1139
+ charger, created = Charger.objects.get_or_create(
1140
+ charger_id=serial,
1141
+ connector_id=connector_value,
1142
+ )
1143
+
1144
+ location_obj = None
1145
+ location_payload = payload.get("location")
1146
+ if isinstance(location_payload, Mapping):
1147
+ name = location_payload.get("name")
1148
+ if name:
1149
+ location_obj, _ = Location.objects.get_or_create(name=name)
1150
+ simple_fields = [
1151
+ "latitude",
1152
+ "longitude",
1153
+ "zone",
1154
+ "contract_type",
1155
+ ]
1156
+ for field in simple_fields:
1157
+ value = location_payload.get(field)
1158
+ setattr(location_obj, field, value)
1159
+ location_obj.save()
1160
+
1161
+ datetime_fields = [
1162
+ "firmware_timestamp",
1163
+ "last_heartbeat",
1164
+ "availability_state_updated_at",
1165
+ "availability_requested_at",
1166
+ "availability_request_status_at",
1167
+ "diagnostics_timestamp",
1168
+ "last_status_timestamp",
1169
+ ]
1170
+
1171
+ updates: dict[str, object] = {
1172
+ "node_origin": node,
1173
+ "allow_remote": bool(payload.get("allow_remote", False)),
1174
+ "export_transactions": bool(payload.get("export_transactions", False)),
1175
+ "last_online_at": timezone.now(),
1176
+ }
1177
+
1178
+ simple_fields = [
1179
+ "display_name",
1180
+ "language",
1181
+ "public_display",
1182
+ "require_rfid",
1183
+ "firmware_status",
1184
+ "firmware_status_info",
1185
+ "last_status",
1186
+ "last_error_code",
1187
+ "last_status_vendor_info",
1188
+ "availability_state",
1189
+ "availability_requested_state",
1190
+ "availability_request_status",
1191
+ "availability_request_details",
1192
+ "temperature",
1193
+ "temperature_unit",
1194
+ "diagnostics_status",
1195
+ "diagnostics_location",
1196
+ ]
1197
+ for field in simple_fields:
1198
+ updates[field] = payload.get(field)
1199
+
1200
+ if location_obj is not None:
1201
+ updates["location"] = location_obj
1202
+
1203
+ for field in datetime_fields:
1204
+ value = payload.get(field)
1205
+ updates[field] = parse_datetime(value) if value else None
1206
+
1207
+ for field in ("last_meter_values",):
1208
+ updates[field] = payload.get(field) or {}
1209
+
1210
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1211
+ return "created" if created else "updated"
1212
+
1233
1213
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1234
1214
  extra_context = extra_context or {}
1235
1215
  if object_id:
nodes/tests.py CHANGED
@@ -66,6 +66,7 @@ from .models import (
66
66
  )
67
67
  from .backends import OutboxEmailBackend
68
68
  from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
69
+ from ocpp.models import Charger
69
70
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
70
71
  from cryptography.hazmat.primitives import serialization, hashes
71
72
  from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
@@ -1816,132 +1817,6 @@ class NodeAdminTests(TestCase):
1816
1817
  self.assertEqual(response.status_code, 200)
1817
1818
  self.assertContains(response, "data:image/png;base64")
1818
1819
 
1819
- @patch("nodes.admin.requests.post")
1820
- def test_proxy_view_uses_remote_login_url(self, mock_post):
1821
- self.client.get(reverse("admin:nodes_node_register_current"))
1822
- local_node = Node.objects.get()
1823
- remote = Node.objects.create(
1824
- hostname="remote",
1825
- address="192.0.2.10",
1826
- port=8443,
1827
- mac_address="aa:bb:cc:dd:ee:ff",
1828
- )
1829
- mock_post.return_value = SimpleNamespace(
1830
- ok=True,
1831
- json=lambda: {
1832
- "login_url": "https://remote.example/nodes/proxy/login/token",
1833
- "expires": "2025-01-01T00:00:00",
1834
- },
1835
- status_code=200,
1836
- text="ok",
1837
- )
1838
- response = self.client.get(
1839
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1840
- )
1841
- self.assertEqual(response.status_code, 200)
1842
- self.assertTemplateUsed(response, "admin/nodes/node/proxy.html")
1843
- self.assertContains(response, "<iframe", html=False)
1844
- mock_post.assert_called()
1845
- payload = json.loads(mock_post.call_args[1]["data"])
1846
- self.assertEqual(payload.get("requester"), str(local_node.uuid))
1847
- self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1848
- self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1849
-
1850
- @patch("nodes.admin.requests.post")
1851
- def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
1852
- self.client.get(reverse("admin:nodes_node_register_current"))
1853
- remote = Node.objects.create(
1854
- hostname="remote-https",
1855
- address="198.51.100.20",
1856
- port=443,
1857
- mac_address="aa:bb:cc:dd:ee:10",
1858
- )
1859
- local_node = Node.get_local()
1860
- success_response = SimpleNamespace(
1861
- ok=True,
1862
- json=lambda: {
1863
- "login_url": "http://remote.example/nodes/proxy/login/token",
1864
- "expires": "2025-01-01T00:00:00",
1865
- },
1866
- status_code=200,
1867
- text="ok",
1868
- )
1869
- mock_post.side_effect = [
1870
- SSLError("wrong version number"),
1871
- success_response,
1872
- ]
1873
-
1874
- response = self.client.get(
1875
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1876
- )
1877
-
1878
- self.assertEqual(response.status_code, 200)
1879
- self.assertEqual(mock_post.call_count, 2)
1880
- first_url = mock_post.call_args_list[0].args[0]
1881
- second_url = mock_post.call_args_list[1].args[0]
1882
- self.assertTrue(first_url.startswith("https://"))
1883
- self.assertTrue(second_url.startswith("http://"))
1884
- self.assertIn("/nodes/proxy/session/", second_url)
1885
- payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
1886
- self.assertEqual(payload.get("requester"), str(local_node.uuid))
1887
- self.assertEqual(payload.get("requester_mac"), local_node.mac_address)
1888
- self.assertEqual(payload.get("requester_public_key"), local_node.public_key)
1889
-
1890
- @patch("nodes.admin.requests.post")
1891
- def test_proxy_view_retries_post_after_redirect(self, mock_post):
1892
- self.client.get(reverse("admin:nodes_node_register_current"))
1893
- remote = Node.objects.create(
1894
- hostname="redirect-node",
1895
- public_endpoint="http://remote.example",
1896
- address="198.51.100.30",
1897
- mac_address="aa:bb:cc:dd:ee:20",
1898
- )
1899
-
1900
- redirect_response = SimpleNamespace(
1901
- status_code=301,
1902
- ok=True,
1903
- text="redirect",
1904
- headers={"Location": "https://remote.example/nodes/proxy/session/"},
1905
- )
1906
- success_response = SimpleNamespace(
1907
- status_code=200,
1908
- ok=True,
1909
- text="ok",
1910
- headers={},
1911
- json=lambda: {
1912
- "login_url": "https://remote.example/nodes/proxy/login/token",
1913
- "expires": "2025-01-01T00:00:00",
1914
- },
1915
- )
1916
-
1917
- mock_post.side_effect = [redirect_response, success_response]
1918
-
1919
- response = self.client.get(
1920
- reverse("admin:nodes_node_proxy", args=[remote.pk])
1921
- )
1922
-
1923
- self.assertEqual(response.status_code, 200)
1924
- self.assertEqual(mock_post.call_count, 2)
1925
-
1926
- first_call_kwargs = mock_post.call_args_list[0].kwargs
1927
- self.assertFalse(first_call_kwargs.get("allow_redirects", True))
1928
-
1929
- second_url = mock_post.call_args_list[1].args[0]
1930
- self.assertEqual(second_url, "https://remote.example/nodes/proxy/session/")
1931
- second_call_kwargs = mock_post.call_args_list[1].kwargs
1932
- self.assertFalse(second_call_kwargs.get("allow_redirects", True))
1933
-
1934
- def test_proxy_link_displayed_for_remote_nodes(self):
1935
- Node.objects.create(
1936
- hostname="remote",
1937
- address="203.0.113.1",
1938
- port=8000,
1939
- mac_address="aa:aa:aa:aa:aa:01",
1940
- )
1941
- response = self.client.get(reverse("admin:nodes_node_changelist"))
1942
- proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1943
- self.assertContains(response, proxy_url)
1944
-
1945
1820
  def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
1946
1821
  node_admin = admin.site._registry[Node]
1947
1822
  local_node = self._create_local_node()
@@ -1974,14 +1849,14 @@ class NodeAdminTests(TestCase):
1974
1849
  port=8443,
1975
1850
  )
1976
1851
 
1977
- urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
1852
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/info/"))
1978
1853
 
1979
1854
  self.assertIn(
1980
- "https://example.com:8443/interface/nodes/proxy/session/",
1855
+ "https://example.com:8443/interface/nodes/info/",
1981
1856
  urls,
1982
1857
  )
1983
1858
  self.assertIn(
1984
- "http://example.com:8443/interface/nodes/proxy/session/",
1859
+ "http://example.com:8443/interface/nodes/info/",
1985
1860
  urls,
1986
1861
  )
1987
1862
  combined = "".join(urls)
@@ -3605,6 +3480,82 @@ class NetMessageSignatureTests(TestCase):
3605
3480
  self.assertTrue(signature_one)
3606
3481
  self.assertTrue(signature_two)
3607
3482
  self.assertNotEqual(signature_one, signature_two)
3483
+
3484
+
3485
+ class NetworkChargerActionSecurityTests(TestCase):
3486
+ def setUp(self):
3487
+ self.client = Client()
3488
+ self.local_node = Node.objects.create(
3489
+ hostname="local-node",
3490
+ address="127.0.0.1",
3491
+ port=8000,
3492
+ mac_address="00:aa:bb:cc:dd:10",
3493
+ public_endpoint="local-endpoint",
3494
+ )
3495
+ self.authorized_node = Node.objects.create(
3496
+ hostname="authorized-node",
3497
+ address="127.0.0.2",
3498
+ port=8001,
3499
+ mac_address="00:aa:bb:cc:dd:11",
3500
+ public_endpoint="authorized-endpoint",
3501
+ )
3502
+ self.unauthorized_node, self.unauthorized_key = self._create_signed_node(
3503
+ "unauthorized-node",
3504
+ mac_suffix=0x12,
3505
+ )
3506
+ self.charger = Charger.objects.create(
3507
+ charger_id="SECURE-TEST-1",
3508
+ allow_remote=True,
3509
+ manager_node=self.authorized_node,
3510
+ node_origin=self.local_node,
3511
+ )
3512
+
3513
+ def _create_signed_node(self, hostname: str, *, mac_suffix: int):
3514
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
3515
+ public_bytes = key.public_key().public_bytes(
3516
+ encoding=serialization.Encoding.PEM,
3517
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
3518
+ )
3519
+ node = Node.objects.create(
3520
+ hostname=hostname,
3521
+ address="10.0.0.{:d}".format(mac_suffix),
3522
+ port=8020,
3523
+ mac_address="00:aa:bb:cc:dd:{:02x}".format(mac_suffix),
3524
+ public_key=public_bytes.decode(),
3525
+ public_endpoint=f"{hostname}-endpoint",
3526
+ )
3527
+ return node, key
3528
+
3529
+ def test_rejects_requests_from_unmanaged_nodes(self):
3530
+ url = reverse("node-network-charger-action")
3531
+ payload = {
3532
+ "requester": str(self.unauthorized_node.uuid),
3533
+ "charger_id": self.charger.charger_id,
3534
+ "action": "reset",
3535
+ }
3536
+ body = json.dumps(payload).encode()
3537
+ signature = self.unauthorized_key.sign(
3538
+ body,
3539
+ padding.PKCS1v15(),
3540
+ hashes.SHA256(),
3541
+ )
3542
+ headers = {"HTTP_X_SIGNATURE": base64.b64encode(signature).decode()}
3543
+
3544
+ with patch.object(Node, "get_local", return_value=self.local_node):
3545
+ response = self.client.post(
3546
+ url,
3547
+ data=body,
3548
+ content_type="application/json",
3549
+ **headers,
3550
+ )
3551
+
3552
+ self.assertEqual(response.status_code, 403)
3553
+ self.assertEqual(
3554
+ response.json().get("detail"),
3555
+ "requester does not manage this charger",
3556
+ )
3557
+
3558
+
3608
3559
  class StartupNotificationTests(TestCase):
3609
3560
  def test_startup_notification_uses_hostname_and_revision(self):
3610
3561
  from nodes.apps import _startup_notification
nodes/urls.py CHANGED
@@ -11,6 +11,12 @@ urlpatterns = [
11
11
  path("net-message/pull/", views.net_message_pull, name="net-message-pull"),
12
12
  path("rfid/export/", views.export_rfids, name="node-rfid-export"),
13
13
  path("rfid/import/", views.import_rfids, name="node-rfid-import"),
14
+ path("network/chargers/", views.network_chargers, name="node-network-chargers"),
15
+ path(
16
+ "network/chargers/action/",
17
+ views.network_charger_action,
18
+ name="node-network-charger-action",
19
+ ),
14
20
  path("proxy/session/", views.proxy_session, name="node-proxy-session"),
15
21
  path("proxy/login/<str:token>/", views.proxy_login, name="node-proxy-login"),
16
22
  path("proxy/execute/", views.proxy_execute, name="node-proxy-execute"),