arthexis 0.1.21__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/tests.py CHANGED
@@ -48,6 +48,7 @@ from .classifiers import run_default_classifiers
48
48
  from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
49
49
  from django.db.utils import DatabaseError
50
50
 
51
+ from .admin import NodeAdmin
51
52
  from .models import (
52
53
  Node,
53
54
  EmailOutbox,
@@ -67,7 +68,8 @@ from .backends import OutboxEmailBackend
67
68
  from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
68
69
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
69
70
  from cryptography.hazmat.primitives import serialization, hashes
70
- from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
71
+ from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
72
+ from requests.exceptions import SSLError
71
73
 
72
74
 
73
75
  class NodeBadgeColorTests(TestCase):
@@ -742,6 +744,59 @@ class NodeGetLocalTests(TestCase):
742
744
  self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
743
745
 
744
746
 
747
+ class NodeEnsureKeysTests(TestCase):
748
+ def setUp(self):
749
+ self.tempdir = TemporaryDirectory()
750
+ self.base = Path(self.tempdir.name)
751
+ self.override = override_settings(BASE_DIR=self.base)
752
+ self.override.enable()
753
+ self.node = Node.objects.create(
754
+ hostname="ensure-host",
755
+ address="127.0.0.1",
756
+ port=8000,
757
+ mac_address="00:11:22:33:44:55",
758
+ )
759
+
760
+ def tearDown(self):
761
+ self.override.disable()
762
+ self.tempdir.cleanup()
763
+
764
+ def test_regenerates_missing_keys(self):
765
+ self.node.ensure_keys()
766
+ security_dir = self.base / "security"
767
+ priv_path = security_dir / self.node.public_endpoint
768
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
769
+ original_public = self.node.public_key
770
+ priv_path.unlink()
771
+ pub_path.unlink()
772
+
773
+ self.node.ensure_keys()
774
+
775
+ self.assertTrue(priv_path.exists())
776
+ self.assertTrue(pub_path.exists())
777
+ self.assertNotEqual(self.node.public_key, original_public)
778
+
779
+ def test_regenerates_outdated_keys(self):
780
+ self.node.ensure_keys()
781
+ security_dir = self.base / "security"
782
+ priv_path = security_dir / self.node.public_endpoint
783
+ pub_path = security_dir / f"{self.node.public_endpoint}.pub"
784
+ original_private = priv_path.read_bytes()
785
+ original_public = pub_path.read_bytes()
786
+
787
+ old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
788
+ os.utime(priv_path, (old_time, old_time))
789
+ os.utime(pub_path, (old_time, old_time))
790
+
791
+ with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
792
+ self.node.ensure_keys()
793
+
794
+ self.node.refresh_from_db()
795
+ self.assertNotEqual(priv_path.read_bytes(), original_private)
796
+ self.assertNotEqual(pub_path.read_bytes(), original_public)
797
+ self.assertNotEqual(self.node.public_key, original_public.decode())
798
+
799
+
745
800
  class NodeInfoViewTests(TestCase):
746
801
  def setUp(self):
747
802
  self.mac = "02:00:00:00:00:01"
@@ -790,6 +845,16 @@ class NodeInfoViewTests(TestCase):
790
845
  payload = response.json()
791
846
  self.assertEqual(payload["port"], 8443)
792
847
 
848
+ def test_includes_role_in_payload(self):
849
+ role, _ = NodeRole.objects.get_or_create(name="Terminal")
850
+ self.node.role = role
851
+ self.node.save(update_fields=["role"])
852
+
853
+ response = self.client.get(self.url)
854
+ self.assertEqual(response.status_code, 200)
855
+ payload = response.json()
856
+ self.assertEqual(payload.get("role"), "Terminal")
857
+
793
858
 
794
859
  class RegisterVisitorNodeMessageTests(TestCase):
795
860
  def setUp(self):
@@ -1651,6 +1716,60 @@ class NodeAdminTests(TestCase):
1651
1716
  response, reverse("admin:nodes_node_register_current")
1652
1717
  )
1653
1718
 
1719
+ def test_apply_remote_node_info_updates_role(self):
1720
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1721
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1722
+ node = Node.objects.create(
1723
+ hostname="remote-node",
1724
+ address="10.0.0.20",
1725
+ port=8001,
1726
+ mac_address="00:11:22:33:44:aa",
1727
+ role=terminal,
1728
+ )
1729
+ admin_instance = NodeAdmin(Node, admin.site)
1730
+
1731
+ payload = {
1732
+ "hostname": node.hostname,
1733
+ "address": node.address,
1734
+ "port": node.port,
1735
+ "role": "Control",
1736
+ }
1737
+
1738
+ changed = admin_instance._apply_remote_node_info(node, payload)
1739
+ node.refresh_from_db()
1740
+
1741
+ self.assertIn("role", changed)
1742
+ self.assertEqual(node.role, control)
1743
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1744
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1745
+
1746
+ def test_apply_remote_node_info_accepts_role_name_key(self):
1747
+ terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
1748
+ control, _ = NodeRole.objects.get_or_create(name="Control")
1749
+ node = Node.objects.create(
1750
+ hostname="role-name-node",
1751
+ address="10.0.0.21",
1752
+ port=8002,
1753
+ mac_address="00:11:22:33:44:bb",
1754
+ role=terminal,
1755
+ )
1756
+ admin_instance = NodeAdmin(Node, admin.site)
1757
+
1758
+ payload = {
1759
+ "hostname": node.hostname,
1760
+ "address": node.address,
1761
+ "port": node.port,
1762
+ "role_name": "Control",
1763
+ }
1764
+
1765
+ changed = admin_instance._apply_remote_node_info(node, payload)
1766
+ node.refresh_from_db()
1767
+
1768
+ self.assertIn("role", changed)
1769
+ self.assertEqual(node.role, control)
1770
+ self.assertTrue(control.node_set.filter(pk=node.pk).exists())
1771
+ self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
1772
+
1654
1773
  @pytest.mark.feature("screenshot-poll")
1655
1774
  @patch("nodes.admin.capture_screenshot")
1656
1775
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
@@ -1725,6 +1844,92 @@ class NodeAdminTests(TestCase):
1725
1844
  mock_post.assert_called()
1726
1845
  payload = json.loads(mock_post.call_args[1]["data"])
1727
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))
1728
1933
 
1729
1934
  def test_proxy_link_displayed_for_remote_nodes(self):
1730
1935
  Node.objects.create(
@@ -1737,6 +1942,51 @@ class NodeAdminTests(TestCase):
1737
1942
  proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1738
1943
  self.assertContains(response, proxy_url)
1739
1944
 
1945
+ def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
1946
+ node_admin = admin.site._registry[Node]
1947
+ local_node = self._create_local_node()
1948
+
1949
+ link_html = node_admin.visit_link(local_node)
1950
+
1951
+ self.assertIn(reverse("admin:index"), link_html)
1952
+ self.assertIn("target=\"_blank\"", link_html)
1953
+
1954
+ def test_visit_link_prefers_remote_hostname_for_dashboard(self):
1955
+ node_admin = admin.site._registry[Node]
1956
+ remote = Node.objects.create(
1957
+ hostname="remote.example.com",
1958
+ address="198.51.100.20",
1959
+ port=8443,
1960
+ mac_address="aa:bb:cc:dd:ee:ff",
1961
+ )
1962
+
1963
+ link_html = node_admin.visit_link(remote)
1964
+
1965
+ self.assertIn("https://remote.example.com:8443/admin/", link_html)
1966
+ self.assertIn("target=\"_blank\"", link_html)
1967
+
1968
+ def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
1969
+ node_admin = admin.site._registry[Node]
1970
+ remote = SimpleNamespace(
1971
+ public_endpoint="",
1972
+ address="",
1973
+ hostname="example.com/interface",
1974
+ port=8443,
1975
+ )
1976
+
1977
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
1978
+
1979
+ self.assertIn(
1980
+ "https://example.com:8443/interface/nodes/proxy/session/",
1981
+ urls,
1982
+ )
1983
+ self.assertIn(
1984
+ "http://example.com:8443/interface/nodes/proxy/session/",
1985
+ urls,
1986
+ )
1987
+ combined = "".join(urls)
1988
+ self.assertNotIn("interface:8443", combined)
1989
+
1740
1990
 
1741
1991
  @pytest.mark.feature("screenshot-poll")
1742
1992
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
@@ -2427,6 +2677,32 @@ class NodeProxyGatewayTests(TestCase):
2427
2677
  second = self.client.get(parsed.path)
2428
2678
  self.assertEqual(second.status_code, 410)
2429
2679
 
2680
+ def test_proxy_session_accepts_mac_hint_when_uuid_unknown(self):
2681
+ payload = {
2682
+ "requester": str(uuid.uuid4()),
2683
+ "requester_mac": self.node.mac_address,
2684
+ "requester_public_key": self.node.public_key,
2685
+ "user": {
2686
+ "username": "proxy-user",
2687
+ "email": "proxy@example.com",
2688
+ "first_name": "Proxy",
2689
+ "last_name": "User",
2690
+ "is_staff": True,
2691
+ "is_superuser": True,
2692
+ "groups": [],
2693
+ "permissions": [],
2694
+ },
2695
+ "target": "/admin/",
2696
+ }
2697
+ body, signature = self._sign(payload)
2698
+ response = self.client.post(
2699
+ reverse("node-proxy-session"),
2700
+ data=body,
2701
+ content_type="application/json",
2702
+ HTTP_X_SIGNATURE=signature,
2703
+ )
2704
+ self.assertEqual(response.status_code, 200)
2705
+
2430
2706
  def test_proxy_execute_lists_nodes(self):
2431
2707
  Node.objects.create(
2432
2708
  hostname="target",
@@ -3412,6 +3688,17 @@ class StartupHandlerTests(TestCase):
3412
3688
 
3413
3689
  mock_start.assert_called_once()
3414
3690
 
3691
+ def test_handler_skips_during_migrate_command(self):
3692
+ import sys
3693
+
3694
+ from nodes.apps import _trigger_startup_notification
3695
+
3696
+ with patch("nodes.apps._startup_notification") as mock_start:
3697
+ with patch.object(sys, "argv", ["manage.py", "migrate"]):
3698
+ _trigger_startup_notification()
3699
+
3700
+ mock_start.assert_not_called()
3701
+
3415
3702
 
3416
3703
  class NotificationManagerTests(TestCase):
3417
3704
  def test_send_writes_trimmed_lines(self):
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:
@@ -320,6 +364,7 @@ def node_info(request):
320
364
  "mac_address": node.mac_address,
321
365
  "public_key": node.public_key,
322
366
  "features": list(node.features.values_list("slug", flat=True)),
367
+ "role": node.role.name if node.role_id else "",
323
368
  }
324
369
 
325
370
  if token:
@@ -588,26 +633,21 @@ def export_rfids(request):
588
633
  return JsonResponse({"detail": "invalid json"}, status=400)
589
634
 
590
635
  requester = payload.get("requester")
591
- signature = request.headers.get("X-Signature")
592
636
  if not requester:
593
637
  return JsonResponse({"detail": "requester required"}, status=400)
594
- if not signature:
595
- return JsonResponse({"detail": "signature required"}, status=403)
596
638
 
597
- node = Node.objects.filter(uuid=requester).first()
598
- if not node or not node.public_key:
599
- return JsonResponse({"detail": "unknown requester"}, status=403)
600
-
601
- try:
602
- public_key = serialization.load_pem_public_key(node.public_key.encode())
603
- public_key.verify(
604
- base64.b64decode(signature),
605
- request.body,
606
- padding.PKCS1v15(),
607
- hashes.SHA256(),
608
- )
609
- except Exception:
610
- 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
611
651
 
612
652
  tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
613
653
 
@@ -627,26 +667,21 @@ def import_rfids(request):
627
667
  return JsonResponse({"detail": "invalid json"}, status=400)
628
668
 
629
669
  requester = payload.get("requester")
630
- signature = request.headers.get("X-Signature")
631
670
  if not requester:
632
671
  return JsonResponse({"detail": "requester required"}, status=400)
633
- if not signature:
634
- return JsonResponse({"detail": "signature required"}, status=403)
635
-
636
- node = Node.objects.filter(uuid=requester).first()
637
- if not node or not node.public_key:
638
- return JsonResponse({"detail": "unknown requester"}, status=403)
639
672
 
640
- try:
641
- public_key = serialization.load_pem_public_key(node.public_key.encode())
642
- public_key.verify(
643
- base64.b64decode(signature),
644
- request.body,
645
- padding.PKCS1v15(),
646
- hashes.SHA256(),
647
- )
648
- except Exception:
649
- 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
650
685
 
651
686
  rfids = payload.get("rfids", [])
652
687
  if not isinstance(rfids, list):
@@ -703,7 +738,16 @@ def proxy_session(request):
703
738
  if not requester:
704
739
  return JsonResponse({"detail": "requester required"}, status=400)
705
740
 
706
- 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
+ )
707
751
  if error_response is not None:
708
752
  return error_response
709
753
 
@@ -843,7 +887,16 @@ def proxy_execute(request):
843
887
  if not requester:
844
888
  return JsonResponse({"detail": "requester required"}, status=400)
845
889
 
846
- 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
+ )
847
900
  if error_response is not None:
848
901
  return error_response
849
902
 
ocpp/admin.py CHANGED
@@ -112,12 +112,19 @@ class LogViewAdminMixin:
112
112
 
113
113
  @admin.register(ChargerConfiguration)
114
114
  class ChargerConfigurationAdmin(admin.ModelAdmin):
115
- list_display = ("charger_identifier", "connector_display", "created_at")
115
+ list_display = (
116
+ "charger_identifier",
117
+ "connector_display",
118
+ "origin_display",
119
+ "created_at",
120
+ )
116
121
  list_filter = ("connector_id",)
117
122
  search_fields = ("charger_identifier",)
118
123
  readonly_fields = (
119
124
  "charger_identifier",
120
125
  "connector_id",
126
+ "origin_display",
127
+ "evcs_snapshot_at",
121
128
  "created_at",
122
129
  "updated_at",
123
130
  "linked_chargers",
@@ -132,6 +139,8 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
132
139
  "fields": (
133
140
  "charger_identifier",
134
141
  "connector_id",
142
+ "origin_display",
143
+ "evcs_snapshot_at",
135
144
  "linked_chargers",
136
145
  "created_at",
137
146
  "updated_at",
@@ -185,11 +194,21 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
185
194
  def raw_payload_display(self, obj):
186
195
  return self._render_json(obj.raw_payload)
187
196
 
197
+ @admin.display(description="Origin")
198
+ def origin_display(self, obj):
199
+ if obj.evcs_snapshot_at:
200
+ return "EVCS"
201
+ return "Local"
202
+
203
+ def save_model(self, request, obj, form, change):
204
+ obj.evcs_snapshot_at = None
205
+ super().save_model(request, obj, form, change)
206
+
188
207
 
189
208
  @admin.register(Location)
190
209
  class LocationAdmin(EntityModelAdmin):
191
210
  form = LocationAdminForm
192
- list_display = ("name", "latitude", "longitude")
211
+ list_display = ("name", "zone", "contract_type", "latitude", "longitude")
193
212
  change_form_template = "admin/ocpp/location/change_form.html"
194
213
 
195
214
 
@@ -752,6 +771,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
752
771
  level=messages.ERROR,
753
772
  )
754
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
755
785
  message_id = uuid.uuid4().hex
756
786
  msg = json.dumps([
757
787
  2,
ocpp/consumers.py CHANGED
@@ -791,6 +791,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
791
791
  connector_id=connector_value,
792
792
  configuration_keys=normalized_entries,
793
793
  unknown_keys=unknown_values,
794
+ evcs_snapshot_at=timezone.now(),
794
795
  raw_payload=raw_payload,
795
796
  )
796
797
  Charger.objects.filter(charger_id=self.charger_id).update(