arthexis 0.1.21__py3-none-any.whl → 0.1.22__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):
@@ -1726,6 +1845,44 @@ class NodeAdminTests(TestCase):
1726
1845
  payload = json.loads(mock_post.call_args[1]["data"])
1727
1846
  self.assertEqual(payload.get("requester"), str(local_node.uuid))
1728
1847
 
1848
+ @patch("nodes.admin.requests.post")
1849
+ def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
1850
+ self.client.get(reverse("admin:nodes_node_register_current"))
1851
+ remote = Node.objects.create(
1852
+ hostname="remote-https",
1853
+ address="198.51.100.20",
1854
+ port=443,
1855
+ mac_address="aa:bb:cc:dd:ee:10",
1856
+ )
1857
+ local_node = Node.get_local()
1858
+ success_response = SimpleNamespace(
1859
+ ok=True,
1860
+ json=lambda: {
1861
+ "login_url": "http://remote.example/nodes/proxy/login/token",
1862
+ "expires": "2025-01-01T00:00:00",
1863
+ },
1864
+ status_code=200,
1865
+ text="ok",
1866
+ )
1867
+ mock_post.side_effect = [
1868
+ SSLError("wrong version number"),
1869
+ success_response,
1870
+ ]
1871
+
1872
+ response = self.client.get(
1873
+ reverse("admin:nodes_node_proxy", args=[remote.pk])
1874
+ )
1875
+
1876
+ self.assertEqual(response.status_code, 200)
1877
+ self.assertEqual(mock_post.call_count, 2)
1878
+ first_url = mock_post.call_args_list[0].args[0]
1879
+ second_url = mock_post.call_args_list[1].args[0]
1880
+ self.assertTrue(first_url.startswith("https://"))
1881
+ self.assertTrue(second_url.startswith("http://"))
1882
+ self.assertIn("/nodes/proxy/session/", second_url)
1883
+ payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
1884
+ self.assertEqual(payload.get("requester"), str(local_node.uuid))
1885
+
1729
1886
  def test_proxy_link_displayed_for_remote_nodes(self):
1730
1887
  Node.objects.create(
1731
1888
  hostname="remote",
@@ -1737,6 +1894,51 @@ class NodeAdminTests(TestCase):
1737
1894
  proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1738
1895
  self.assertContains(response, proxy_url)
1739
1896
 
1897
+ def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
1898
+ node_admin = admin.site._registry[Node]
1899
+ local_node = self._create_local_node()
1900
+
1901
+ link_html = node_admin.visit_link(local_node)
1902
+
1903
+ self.assertIn(reverse("admin:index"), link_html)
1904
+ self.assertIn("target=\"_blank\"", link_html)
1905
+
1906
+ def test_visit_link_prefers_remote_hostname_for_dashboard(self):
1907
+ node_admin = admin.site._registry[Node]
1908
+ remote = Node.objects.create(
1909
+ hostname="remote.example.com",
1910
+ address="198.51.100.20",
1911
+ port=8443,
1912
+ mac_address="aa:bb:cc:dd:ee:ff",
1913
+ )
1914
+
1915
+ link_html = node_admin.visit_link(remote)
1916
+
1917
+ self.assertIn("https://remote.example.com:8443/admin/", link_html)
1918
+ self.assertIn("target=\"_blank\"", link_html)
1919
+
1920
+ def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
1921
+ node_admin = admin.site._registry[Node]
1922
+ remote = SimpleNamespace(
1923
+ public_endpoint="",
1924
+ address="",
1925
+ hostname="example.com/interface",
1926
+ port=8443,
1927
+ )
1928
+
1929
+ urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
1930
+
1931
+ self.assertIn(
1932
+ "https://example.com:8443/interface/nodes/proxy/session/",
1933
+ urls,
1934
+ )
1935
+ self.assertIn(
1936
+ "http://example.com:8443/interface/nodes/proxy/session/",
1937
+ urls,
1938
+ )
1939
+ combined = "".join(urls)
1940
+ self.assertNotIn("interface:8443", combined)
1941
+
1740
1942
 
1741
1943
  @pytest.mark.feature("screenshot-poll")
1742
1944
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
@@ -3412,6 +3614,17 @@ class StartupHandlerTests(TestCase):
3412
3614
 
3413
3615
  mock_start.assert_called_once()
3414
3616
 
3617
+ def test_handler_skips_during_migrate_command(self):
3618
+ import sys
3619
+
3620
+ from nodes.apps import _trigger_startup_notification
3621
+
3622
+ with patch("nodes.apps._startup_notification") as mock_start:
3623
+ with patch.object(sys, "argv", ["manage.py", "migrate"]):
3624
+ _trigger_startup_notification()
3625
+
3626
+ mock_start.assert_not_called()
3627
+
3415
3628
 
3416
3629
  class NotificationManagerTests(TestCase):
3417
3630
  def test_send_writes_trimmed_lines(self):
nodes/views.py CHANGED
@@ -320,6 +320,7 @@ def node_info(request):
320
320
  "mac_address": node.mac_address,
321
321
  "public_key": node.public_key,
322
322
  "features": list(node.features.values_list("slug", flat=True)),
323
+ "role": node.role.name if node.role_id else "",
323
324
  }
324
325
 
325
326
  if token:
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,6 +194,16 @@ 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):
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(
ocpp/models.py CHANGED
@@ -48,6 +48,7 @@ class Charger(Entity):
48
48
  """Known charge point."""
49
49
 
50
50
  _PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
51
+ _AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
51
52
 
52
53
  OPERATIVE_STATUSES = {
53
54
  "Available",
@@ -324,6 +325,16 @@ class Charger(Entity):
324
325
  )
325
326
  return normalized
326
327
 
328
+ @classmethod
329
+ def sanitize_auto_location_name(cls, value: str) -> str:
330
+ """Return a location name containing only safe characters."""
331
+
332
+ sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
333
+ sanitized = re.sub(r"_+", "_", sanitized).strip("_")
334
+ if not sanitized:
335
+ return "Charger"
336
+ return sanitized
337
+
327
338
  AGGREGATE_CONNECTOR_SLUG = "all"
328
339
 
329
340
  def identity_tuple(self) -> tuple[str, int | None]:
@@ -459,7 +470,8 @@ class Charger(Entity):
459
470
  if existing:
460
471
  self.location = existing.location
461
472
  else:
462
- location, _ = Location.objects.get_or_create(name=self.charger_id)
473
+ auto_name = type(self).sanitize_auto_location_name(self.charger_id)
474
+ location, _ = Location.objects.get_or_create(name=auto_name)
463
475
  self.location = location
464
476
  if update_list is not None and "location" not in update_list:
465
477
  update_list.append("location")
@@ -663,6 +675,14 @@ class ChargerConfiguration(models.Model):
663
675
  blank=True,
664
676
  help_text=_("Keys returned in the unknownKey list."),
665
677
  )
678
+ evcs_snapshot_at = models.DateTimeField(
679
+ _("EVCS snapshot at"),
680
+ null=True,
681
+ blank=True,
682
+ help_text=_(
683
+ "Timestamp when this configuration was received from the charge point."
684
+ ),
685
+ )
666
686
  raw_payload = models.JSONField(
667
687
  default=dict,
668
688
  blank=True,
@@ -1023,6 +1043,8 @@ class DataTransferMessage(models.Model):
1023
1043
 
1024
1044
  class Meta:
1025
1045
  ordering = ["-created_at"]
1046
+ verbose_name = _("Data Message")
1047
+ verbose_name_plural = _("Data Messages")
1026
1048
  indexes = [
1027
1049
  models.Index(
1028
1050
  fields=["ocpp_message_id"],
ocpp/tasks.py CHANGED
@@ -1,7 +1,10 @@
1
+ import json
1
2
  import logging
3
+ import uuid
2
4
  from datetime import date, datetime, time, timedelta
3
5
  from pathlib import Path
4
6
 
7
+ from asgiref.sync import async_to_sync
5
8
  from celery import shared_task
6
9
  from django.conf import settings
7
10
  from django.contrib.auth import get_user_model
@@ -11,11 +14,106 @@ from django.db.models import Q
11
14
  from core import mailer
12
15
  from nodes.models import Node
13
16
 
14
- from .models import MeterValue, Transaction
17
+ from . import store
18
+ from .models import Charger, MeterValue, Transaction
15
19
 
16
20
  logger = logging.getLogger(__name__)
17
21
 
18
22
 
23
+ @shared_task
24
+ def check_charge_point_configuration(charger_pk: int) -> bool:
25
+ """Request the latest configuration from a connected charge point."""
26
+
27
+ try:
28
+ charger = Charger.objects.get(pk=charger_pk)
29
+ except Charger.DoesNotExist:
30
+ logger.warning(
31
+ "Unable to request configuration for missing charger %s",
32
+ charger_pk,
33
+ )
34
+ return False
35
+
36
+ connector_value = charger.connector_id
37
+ if connector_value is not None:
38
+ logger.debug(
39
+ "Skipping charger %s: connector %s is not eligible for automatic configuration checks",
40
+ charger.charger_id,
41
+ connector_value,
42
+ )
43
+ return False
44
+
45
+ ws = store.get_connection(charger.charger_id, connector_value)
46
+ if ws is None:
47
+ logger.info(
48
+ "Charge point %s is not connected; configuration request skipped",
49
+ charger.charger_id,
50
+ )
51
+ return False
52
+
53
+ message_id = uuid.uuid4().hex
54
+ payload: dict[str, object] = {}
55
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
56
+
57
+ try:
58
+ async_to_sync(ws.send)(msg)
59
+ except Exception as exc: # pragma: no cover - network error
60
+ logger.warning(
61
+ "Failed to send GetConfiguration to %s (%s)",
62
+ charger.charger_id,
63
+ exc,
64
+ )
65
+ return False
66
+
67
+ log_key = store.identity_key(charger.charger_id, connector_value)
68
+ store.add_log(log_key, f"< {msg}", log_type="charger")
69
+ store.register_pending_call(
70
+ message_id,
71
+ {
72
+ "action": "GetConfiguration",
73
+ "charger_id": charger.charger_id,
74
+ "connector_id": connector_value,
75
+ "log_key": log_key,
76
+ "requested_at": timezone.now(),
77
+ },
78
+ )
79
+ store.schedule_call_timeout(
80
+ message_id,
81
+ timeout=5.0,
82
+ action="GetConfiguration",
83
+ log_key=log_key,
84
+ message=(
85
+ "GetConfiguration timed out: charger did not respond"
86
+ " (operation may not be supported)"
87
+ ),
88
+ )
89
+ logger.info(
90
+ "Requested configuration from charge point %s",
91
+ charger.charger_id,
92
+ )
93
+ return True
94
+
95
+
96
+ @shared_task
97
+ def schedule_daily_charge_point_configuration_checks() -> int:
98
+ """Dispatch configuration requests for eligible charge points."""
99
+
100
+ charger_ids = list(
101
+ Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
102
+ )
103
+ if not charger_ids:
104
+ logger.debug("No eligible charge points available for configuration check")
105
+ return 0
106
+
107
+ scheduled = 0
108
+ for charger_pk in charger_ids:
109
+ check_charge_point_configuration.delay(charger_pk)
110
+ scheduled += 1
111
+ logger.info(
112
+ "Scheduled configuration checks for %s charge point(s)", scheduled
113
+ )
114
+ return scheduled
115
+
116
+
19
117
  @shared_task
20
118
  def purge_meter_values() -> int:
21
119
  """Delete meter values older than 7 days.