arthexis 0.1.20__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,118 @@ 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
+
800
+ class NodeInfoViewTests(TestCase):
801
+ def setUp(self):
802
+ self.mac = "02:00:00:00:00:01"
803
+ self.patcher = patch("nodes.models.Node.get_current_mac", return_value=self.mac)
804
+ self.patcher.start()
805
+ self.addCleanup(self.patcher.stop)
806
+ self.node = Node.objects.create(
807
+ hostname="local",
808
+ address="10.0.0.10",
809
+ port=8000,
810
+ mac_address=self.mac,
811
+ public_endpoint="local",
812
+ current_relation=Node.Relation.SELF,
813
+ )
814
+ self.url = reverse("node-info")
815
+
816
+ def test_returns_https_port_for_secure_domain_request(self):
817
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
818
+ response = self.client.get(
819
+ self.url,
820
+ secure=True,
821
+ HTTP_HOST="arthexis.com",
822
+ )
823
+ self.assertEqual(response.status_code, 200)
824
+ payload = response.json()
825
+ self.assertEqual(payload["port"], 443)
826
+
827
+ def test_returns_http_port_for_plain_domain_request(self):
828
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
829
+ response = self.client.get(
830
+ self.url,
831
+ HTTP_HOST="arthexis.com",
832
+ )
833
+ self.assertEqual(response.status_code, 200)
834
+ payload = response.json()
835
+ self.assertEqual(payload["port"], 80)
836
+
837
+ def test_preserves_explicit_port_in_host_header(self):
838
+ with self.settings(ALLOWED_HOSTS=["arthexis.com"]):
839
+ response = self.client.get(
840
+ self.url,
841
+ secure=True,
842
+ HTTP_HOST="arthexis.com:8443",
843
+ )
844
+ self.assertEqual(response.status_code, 200)
845
+ payload = response.json()
846
+ self.assertEqual(payload["port"], 8443)
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
+
858
+
745
859
  class RegisterVisitorNodeMessageTests(TestCase):
746
860
  def setUp(self):
747
861
  self.client = Client()
@@ -1602,6 +1716,60 @@ class NodeAdminTests(TestCase):
1602
1716
  response, reverse("admin:nodes_node_register_current")
1603
1717
  )
1604
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
+
1605
1773
  @pytest.mark.feature("screenshot-poll")
1606
1774
  @patch("nodes.admin.capture_screenshot")
1607
1775
  def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
@@ -1677,6 +1845,44 @@ class NodeAdminTests(TestCase):
1677
1845
  payload = json.loads(mock_post.call_args[1]["data"])
1678
1846
  self.assertEqual(payload.get("requester"), str(local_node.uuid))
1679
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
+
1680
1886
  def test_proxy_link_displayed_for_remote_nodes(self):
1681
1887
  Node.objects.create(
1682
1888
  hostname="remote",
@@ -1688,6 +1894,51 @@ class NodeAdminTests(TestCase):
1688
1894
  proxy_url = reverse("admin:nodes_node_proxy", args=[1])
1689
1895
  self.assertContains(response, proxy_url)
1690
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
+
1691
1942
 
1692
1943
  @pytest.mark.feature("screenshot-poll")
1693
1944
  @override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
@@ -3363,6 +3614,17 @@ class StartupHandlerTests(TestCase):
3363
3614
 
3364
3615
  mock_start.assert_called_once()
3365
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
+
3366
3628
 
3367
3629
  class NotificationManagerTests(TestCase):
3368
3630
  def test_send_writes_trimmed_lines(self):
nodes/views.py CHANGED
@@ -204,6 +204,60 @@ def _get_host_domain(request) -> str:
204
204
  return ""
205
205
 
206
206
 
207
+ def _normalize_port(value: str | int | None) -> int | None:
208
+ """Return ``value`` as an integer port number when valid."""
209
+
210
+ if value in (None, ""):
211
+ return None
212
+ try:
213
+ port = int(value)
214
+ except (TypeError, ValueError):
215
+ return None
216
+ if port <= 0 or port > 65535:
217
+ return None
218
+ return port
219
+
220
+
221
+ def _get_host_port(request) -> int | None:
222
+ """Return the port implied by the current request if available."""
223
+
224
+ forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
225
+ "HTTP_X_FORWARDED_PORT"
226
+ )
227
+ port = _normalize_port(forwarded_port)
228
+ if port:
229
+ return port
230
+
231
+ try:
232
+ host = request.get_host()
233
+ except Exception: # pragma: no cover - defensive
234
+ host = ""
235
+ if host:
236
+ _, host_port = split_domain_port(host)
237
+ port = _normalize_port(host_port)
238
+ if port:
239
+ return port
240
+
241
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
242
+ if forwarded_proto:
243
+ scheme = forwarded_proto.split(",")[0].strip().lower()
244
+ if scheme == "https":
245
+ return 443
246
+ if scheme == "http":
247
+ return 80
248
+
249
+ if request.is_secure():
250
+ return 443
251
+
252
+ scheme = getattr(request, "scheme", "")
253
+ if scheme.lower() == "https":
254
+ return 443
255
+ if scheme.lower() == "http":
256
+ return 80
257
+
258
+ return None
259
+
260
+
207
261
  def _get_advertised_address(request, node) -> str:
208
262
  """Return the best address for the client to reach this node."""
209
263
 
@@ -245,6 +299,11 @@ def node_info(request):
245
299
  token = request.GET.get("token", "")
246
300
  host_domain = _get_host_domain(request)
247
301
  advertised_address = _get_advertised_address(request, node)
302
+ advertised_port = node.port
303
+ if host_domain:
304
+ host_port = _get_host_port(request)
305
+ if host_port:
306
+ advertised_port = host_port
248
307
  if host_domain:
249
308
  hostname = host_domain
250
309
  if advertised_address and advertised_address != node.address:
@@ -257,10 +316,11 @@ def node_info(request):
257
316
  data = {
258
317
  "hostname": hostname,
259
318
  "address": address,
260
- "port": node.port,
319
+ "port": advertised_port,
261
320
  "mac_address": node.mac_address,
262
321
  "public_key": node.public_key,
263
322
  "features": list(node.features.values_list("slug", flat=True)),
323
+ "role": node.role.name if node.role_id else "",
264
324
  }
265
325
 
266
326
  if token:
ocpp/admin.py CHANGED
@@ -2,11 +2,12 @@ from django.contrib import admin, messages
2
2
  from django import forms
3
3
 
4
4
  import asyncio
5
- from datetime import timedelta
5
+ from datetime import datetime, time, timedelta
6
6
  import json
7
7
 
8
8
  from django.shortcuts import redirect
9
9
  from django.utils import formats, timezone, translation
10
+ from django.utils.html import format_html
10
11
  from django.urls import path
11
12
  from django.http import HttpResponse, HttpResponseRedirect
12
13
  from django.template.response import TemplateResponse
@@ -111,12 +112,19 @@ class LogViewAdminMixin:
111
112
 
112
113
  @admin.register(ChargerConfiguration)
113
114
  class ChargerConfigurationAdmin(admin.ModelAdmin):
114
- list_display = ("charger_identifier", "connector_display", "created_at")
115
+ list_display = (
116
+ "charger_identifier",
117
+ "connector_display",
118
+ "origin_display",
119
+ "created_at",
120
+ )
115
121
  list_filter = ("connector_id",)
116
122
  search_fields = ("charger_identifier",)
117
123
  readonly_fields = (
118
124
  "charger_identifier",
119
125
  "connector_id",
126
+ "origin_display",
127
+ "evcs_snapshot_at",
120
128
  "created_at",
121
129
  "updated_at",
122
130
  "linked_chargers",
@@ -131,6 +139,8 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
131
139
  "fields": (
132
140
  "charger_identifier",
133
141
  "connector_id",
142
+ "origin_display",
143
+ "evcs_snapshot_at",
134
144
  "linked_chargers",
135
145
  "created_at",
136
146
  "updated_at",
@@ -184,6 +194,16 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
184
194
  def raw_payload_display(self, obj):
185
195
  return self._render_json(obj.raw_payload)
186
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
+
187
207
 
188
208
  @admin.register(Location)
189
209
  class LocationAdmin(EntityModelAdmin):
@@ -318,7 +338,7 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
318
338
  list_display = (
319
339
  "display_name_with_fallback",
320
340
  "connector_number",
321
- "serial_number_display",
341
+ "charger_name_display",
322
342
  "require_rfid_display",
323
343
  "public_display",
324
344
  "last_heartbeat",
@@ -429,16 +449,19 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
449
 
430
450
  @admin.display(description="Display Name", ordering="display_name")
431
451
  def display_name_with_fallback(self, obj):
452
+ return self._charger_display_name(obj)
453
+
454
+ @admin.display(description="Charger", ordering="display_name")
455
+ def charger_name_display(self, obj):
456
+ return self._charger_display_name(obj)
457
+
458
+ def _charger_display_name(self, obj):
432
459
  if obj.display_name:
433
460
  return obj.display_name
434
461
  if obj.location:
435
462
  return obj.location.name
436
463
  return obj.charger_id
437
464
 
438
- @admin.display(description="Serial Number", ordering="charger_id")
439
- def serial_number_display(self, obj):
440
- return obj.charger_id
441
-
442
465
  def location_name(self, obj):
443
466
  return obj.location.name if obj.location else ""
444
467
 
@@ -800,6 +823,44 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
800
823
 
801
824
  session_kw.short_description = "Session kW"
802
825
 
826
+ def changelist_view(self, request, extra_context=None):
827
+ response = super().changelist_view(request, extra_context=extra_context)
828
+ if hasattr(response, "context_data"):
829
+ cl = response.context_data.get("cl")
830
+ if cl is not None:
831
+ response.context_data.update(
832
+ self._charger_quick_stats_context(cl.queryset)
833
+ )
834
+ return response
835
+
836
+ def _charger_quick_stats_context(self, queryset):
837
+ chargers = list(queryset)
838
+ stats = {"total_kw": 0.0, "today_kw": 0.0}
839
+ if not chargers:
840
+ return {"charger_quick_stats": stats}
841
+
842
+ parent_ids = {c.charger_id for c in chargers if c.connector_id is None}
843
+ start, end = self._today_range()
844
+
845
+ for charger in chargers:
846
+ include_totals = True
847
+ if charger.connector_id is not None and charger.charger_id in parent_ids:
848
+ include_totals = False
849
+ if include_totals:
850
+ stats["total_kw"] += charger.total_kw
851
+ stats["today_kw"] += charger.total_kw_for_range(start, end)
852
+
853
+ stats = {key: round(value, 2) for key, value in stats.items()}
854
+ return {"charger_quick_stats": stats}
855
+
856
+ def _today_range(self):
857
+ today = timezone.localdate()
858
+ start = datetime.combine(today, time.min)
859
+ if timezone.is_naive(start):
860
+ start = timezone.make_aware(start, timezone.get_current_timezone())
861
+ end = start + timedelta(days=1)
862
+ return start, end
863
+
803
864
 
804
865
  @admin.register(Simulator)
805
866
  class SimulatorAdmin(SaveBeforeChangeAction, LogViewAdminMixin, 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")
@@ -544,6 +556,20 @@ class Charger(Entity):
544
556
  return qs
545
557
  return qs.filter(pk=self.pk)
546
558
 
559
+ def total_kw_for_range(
560
+ self,
561
+ start=None,
562
+ end=None,
563
+ ) -> float:
564
+ """Return total energy delivered within ``start``/``end`` window."""
565
+
566
+ from . import store
567
+
568
+ total = 0.0
569
+ for charger in self._target_chargers():
570
+ total += charger._total_kw_range_single(store, start, end)
571
+ return total
572
+
547
573
  def _total_kw_single(self, store_module) -> float:
548
574
  """Return total kW for this specific charger identity."""
549
575
 
@@ -564,6 +590,40 @@ class Charger(Entity):
564
590
  total += kw
565
591
  return total
566
592
 
593
+ def _total_kw_range_single(self, store_module, start=None, end=None) -> float:
594
+ """Return total kW for a date range for this charger."""
595
+
596
+ tx_active = None
597
+ if self.connector_id is not None:
598
+ tx_active = store_module.get_transaction(self.charger_id, self.connector_id)
599
+
600
+ qs = self.transactions.all()
601
+ if start is not None:
602
+ qs = qs.filter(start_time__gte=start)
603
+ if end is not None:
604
+ qs = qs.filter(start_time__lt=end)
605
+ if tx_active and tx_active.pk is not None:
606
+ qs = qs.exclude(pk=tx_active.pk)
607
+
608
+ total = 0.0
609
+ for tx in qs:
610
+ kw = tx.kw
611
+ if kw:
612
+ total += kw
613
+
614
+ if tx_active:
615
+ start_time = getattr(tx_active, "start_time", None)
616
+ include = True
617
+ if start is not None and start_time and start_time < start:
618
+ include = False
619
+ if end is not None and start_time and start_time >= end:
620
+ include = False
621
+ if include:
622
+ kw = tx_active.kw
623
+ if kw:
624
+ total += kw
625
+ return total
626
+
567
627
  def purge(self):
568
628
  from . import store
569
629
 
@@ -615,6 +675,14 @@ class ChargerConfiguration(models.Model):
615
675
  blank=True,
616
676
  help_text=_("Keys returned in the unknownKey list."),
617
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
+ )
618
686
  raw_payload = models.JSONField(
619
687
  default=dict,
620
688
  blank=True,
@@ -975,6 +1043,8 @@ class DataTransferMessage(models.Model):
975
1043
 
976
1044
  class Meta:
977
1045
  ordering = ["-created_at"]
1046
+ verbose_name = _("Data Message")
1047
+ verbose_name_plural = _("Data Messages")
978
1048
  indexes = [
979
1049
  models.Index(
980
1050
  fields=["ocpp_message_id"],