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.
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/METADATA +10 -11
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/RECORD +34 -36
- config/asgi.py +1 -15
- config/settings.py +4 -26
- config/urls.py +5 -1
- core/admin.py +140 -252
- core/apps.py +0 -6
- core/environment.py +2 -220
- core/models.py +425 -77
- core/system.py +76 -0
- core/tests.py +153 -15
- core/views.py +35 -97
- nodes/admin.py +165 -32
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +263 -1
- nodes/views.py +61 -1
- ocpp/admin.py +68 -7
- ocpp/consumers.py +1 -0
- ocpp/models.py +71 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +310 -2
- ocpp/views.py +365 -5
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/context_processors.py +0 -12
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -63
- pages/urls.py +5 -1
- pages/views.py +264 -16
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.20.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
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":
|
|
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 = (
|
|
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
|
-
"
|
|
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
|
-
|
|
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"],
|