arthexis 0.1.22__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.
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/METADATA +2 -1
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/RECORD +22 -22
- core/admin.py +85 -13
- core/models.py +484 -63
- core/release.py +0 -5
- core/tests.py +29 -1
- core/user_data.py +42 -2
- core/views.py +33 -26
- nodes/admin.py +64 -23
- nodes/models.py +9 -1
- nodes/tests.py +74 -0
- nodes/views.py +100 -48
- ocpp/admin.py +12 -1
- ocpp/models.py +29 -2
- ocpp/tests.py +123 -0
- ocpp/views.py +19 -3
- pages/tests.py +25 -6
- pages/urls.py +5 -0
- pages/views.py +84 -8
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/WHEEL +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.22.dist-info → arthexis-0.1.23.dist-info}/top_level.txt +0 -0
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -589,26 +633,21 @@ def export_rfids(request):
|
|
|
589
633
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
590
634
|
|
|
591
635
|
requester = payload.get("requester")
|
|
592
|
-
signature = request.headers.get("X-Signature")
|
|
593
636
|
if not requester:
|
|
594
637
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
595
|
-
if not signature:
|
|
596
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
597
638
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
except Exception:
|
|
611
|
-
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
|
|
612
651
|
|
|
613
652
|
tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
614
653
|
|
|
@@ -628,26 +667,21 @@ def import_rfids(request):
|
|
|
628
667
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
629
668
|
|
|
630
669
|
requester = payload.get("requester")
|
|
631
|
-
signature = request.headers.get("X-Signature")
|
|
632
670
|
if not requester:
|
|
633
671
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
634
|
-
if not signature:
|
|
635
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
636
|
-
|
|
637
|
-
node = Node.objects.filter(uuid=requester).first()
|
|
638
|
-
if not node or not node.public_key:
|
|
639
|
-
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
640
672
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
651
685
|
|
|
652
686
|
rfids = payload.get("rfids", [])
|
|
653
687
|
if not isinstance(rfids, list):
|
|
@@ -704,7 +738,16 @@ def proxy_session(request):
|
|
|
704
738
|
if not requester:
|
|
705
739
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
706
740
|
|
|
707
|
-
|
|
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
|
+
)
|
|
708
751
|
if error_response is not None:
|
|
709
752
|
return error_response
|
|
710
753
|
|
|
@@ -844,7 +887,16 @@ def proxy_execute(request):
|
|
|
844
887
|
if not requester:
|
|
845
888
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
846
889
|
|
|
847
|
-
|
|
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
|
+
)
|
|
848
900
|
if error_response is not None:
|
|
849
901
|
return error_response
|
|
850
902
|
|
ocpp/admin.py
CHANGED
|
@@ -208,7 +208,7 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
208
208
|
@admin.register(Location)
|
|
209
209
|
class LocationAdmin(EntityModelAdmin):
|
|
210
210
|
form = LocationAdminForm
|
|
211
|
-
list_display = ("name", "latitude", "longitude")
|
|
211
|
+
list_display = ("name", "zone", "contract_type", "latitude", "longitude")
|
|
212
212
|
change_form_template = "admin/ocpp/location/change_form.html"
|
|
213
213
|
|
|
214
214
|
|
|
@@ -771,6 +771,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
|
|
|
771
771
|
level=messages.ERROR,
|
|
772
772
|
)
|
|
773
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
|
|
774
785
|
message_id = uuid.uuid4().hex
|
|
775
786
|
msg = json.dumps([
|
|
776
787
|
2,
|
ocpp/models.py
CHANGED
|
@@ -15,6 +15,7 @@ from nodes.models import Node
|
|
|
15
15
|
|
|
16
16
|
from core.models import (
|
|
17
17
|
EnergyAccount,
|
|
18
|
+
EnergyTariff,
|
|
18
19
|
Reference,
|
|
19
20
|
RFID as CoreRFID,
|
|
20
21
|
ElectricVehicle as CoreElectricVehicle,
|
|
@@ -35,6 +36,22 @@ class Location(Entity):
|
|
|
35
36
|
longitude = models.DecimalField(
|
|
36
37
|
max_digits=9, decimal_places=6, null=True, blank=True
|
|
37
38
|
)
|
|
39
|
+
zone = models.CharField(
|
|
40
|
+
max_length=3,
|
|
41
|
+
choices=EnergyTariff.Zone.choices,
|
|
42
|
+
blank=True,
|
|
43
|
+
null=True,
|
|
44
|
+
help_text=_("CFE climate zone used to select matching energy tariffs."),
|
|
45
|
+
)
|
|
46
|
+
contract_type = models.CharField(
|
|
47
|
+
max_length=16,
|
|
48
|
+
choices=EnergyTariff.ContractType.choices,
|
|
49
|
+
blank=True,
|
|
50
|
+
null=True,
|
|
51
|
+
help_text=_(
|
|
52
|
+
"CFE service contract type required to match energy tariff pricing."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
38
55
|
|
|
39
56
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
40
57
|
return self.name
|
|
@@ -481,11 +498,17 @@ class Charger(Entity):
|
|
|
481
498
|
ref_value = self._full_url()
|
|
482
499
|
if url_targets_local_loopback(ref_value):
|
|
483
500
|
return
|
|
484
|
-
if not self.reference
|
|
501
|
+
if not self.reference:
|
|
485
502
|
self.reference = Reference.objects.create(
|
|
486
503
|
value=ref_value, alt_text=self.charger_id
|
|
487
504
|
)
|
|
488
505
|
super().save(update_fields=["reference"])
|
|
506
|
+
elif self.reference.value != ref_value:
|
|
507
|
+
Reference.objects.filter(pk=self.reference_id).update(
|
|
508
|
+
value=ref_value, alt_text=self.charger_id
|
|
509
|
+
)
|
|
510
|
+
self.reference.value = ref_value
|
|
511
|
+
self.reference.alt_text = self.charger_id
|
|
489
512
|
|
|
490
513
|
def refresh_manager_node(self, node: Node | None = None) -> Node | None:
|
|
491
514
|
"""Ensure ``manager_node`` matches the provided or local node."""
|
|
@@ -783,7 +806,11 @@ class Transaction(Entity):
|
|
|
783
806
|
def vehicle_identifier(self) -> str:
|
|
784
807
|
"""Return the preferred vehicle identifier for this transaction."""
|
|
785
808
|
|
|
786
|
-
|
|
809
|
+
vid = (self.vid or "").strip()
|
|
810
|
+
if vid:
|
|
811
|
+
return vid
|
|
812
|
+
|
|
813
|
+
return (self.vin or "").strip()
|
|
787
814
|
|
|
788
815
|
@property
|
|
789
816
|
def vehicle_identifier_source(self) -> str:
|
ocpp/tests.py
CHANGED
|
@@ -177,6 +177,36 @@ class DispatchActionTests(TestCase):
|
|
|
177
177
|
self.assertEqual(metadata.get("trigger_target"), "BootNotification")
|
|
178
178
|
self.assertEqual(metadata.get("log_key"), log_key)
|
|
179
179
|
|
|
180
|
+
def test_reset_rejected_when_transaction_active(self):
|
|
181
|
+
charger = Charger.objects.create(charger_id="RESETBLOCK")
|
|
182
|
+
dummy = DummyWebSocket()
|
|
183
|
+
connection_key = store.set_connection(charger.charger_id, charger.connector_id, dummy)
|
|
184
|
+
self.addCleanup(lambda: store.connections.pop(connection_key, None))
|
|
185
|
+
tx_obj = Transaction.objects.create(
|
|
186
|
+
charger=charger,
|
|
187
|
+
connector_id=charger.connector_id,
|
|
188
|
+
start_time=timezone.now(),
|
|
189
|
+
)
|
|
190
|
+
tx_key = store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
191
|
+
self.addCleanup(lambda: store.transactions.pop(tx_key, None))
|
|
192
|
+
|
|
193
|
+
request = self.factory.post(
|
|
194
|
+
"/chargers/RESETBLOCK/action/",
|
|
195
|
+
data=json.dumps({"action": "reset"}),
|
|
196
|
+
content_type="application/json",
|
|
197
|
+
)
|
|
198
|
+
request.user = SimpleNamespace(
|
|
199
|
+
is_authenticated=True,
|
|
200
|
+
is_superuser=True,
|
|
201
|
+
is_staff=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
response = dispatch_action(request, charger.charger_id)
|
|
205
|
+
self.assertEqual(response.status_code, 409)
|
|
206
|
+
payload = json.loads(response.content.decode("utf-8"))
|
|
207
|
+
self.assertIn("stop the session first", payload.get("detail", "").lower())
|
|
208
|
+
self.assertFalse(dummy.sent)
|
|
209
|
+
|
|
180
210
|
class ChargerFixtureTests(TestCase):
|
|
181
211
|
fixtures = [
|
|
182
212
|
p.name
|
|
@@ -218,6 +248,62 @@ class ChargerFixtureTests(TestCase):
|
|
|
218
248
|
self.assertEqual(cp2.name, "Simulator #2")
|
|
219
249
|
|
|
220
250
|
|
|
251
|
+
class ChargerRefreshManagerNodeTests(TestCase):
|
|
252
|
+
@classmethod
|
|
253
|
+
def setUpTestData(cls):
|
|
254
|
+
local = Node.objects.create(
|
|
255
|
+
hostname="local-node",
|
|
256
|
+
address="127.0.0.1",
|
|
257
|
+
port=8000,
|
|
258
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
259
|
+
current_relation=Node.Relation.SELF,
|
|
260
|
+
)
|
|
261
|
+
Node.objects.filter(pk=local.pk).update(mac_address="AA:BB:CC:DD:EE:FF")
|
|
262
|
+
cls.local_node = Node.objects.get(pk=local.pk)
|
|
263
|
+
|
|
264
|
+
def test_refresh_manager_node_assigns_local_to_unsaved_charger(self):
|
|
265
|
+
charger = Charger(charger_id="UNSAVED")
|
|
266
|
+
|
|
267
|
+
with patch("nodes.models.Node.get_current_mac", return_value="aa:bb:cc:dd:ee:ff"):
|
|
268
|
+
result = charger.refresh_manager_node()
|
|
269
|
+
|
|
270
|
+
self.assertEqual(result, self.local_node)
|
|
271
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
272
|
+
|
|
273
|
+
def test_refresh_manager_node_updates_persisted_charger(self):
|
|
274
|
+
remote = Node.objects.create(
|
|
275
|
+
hostname="remote-node",
|
|
276
|
+
address="10.0.0.1",
|
|
277
|
+
port=9000,
|
|
278
|
+
mac_address="11:22:33:44:55:66",
|
|
279
|
+
)
|
|
280
|
+
charger = Charger.objects.create(
|
|
281
|
+
charger_id="PERSISTED",
|
|
282
|
+
manager_node=remote,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
charger.refresh_manager_node(node=self.local_node)
|
|
286
|
+
|
|
287
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
288
|
+
charger.refresh_from_db()
|
|
289
|
+
self.assertEqual(charger.manager_node, self.local_node)
|
|
290
|
+
|
|
291
|
+
def test_refresh_manager_node_handles_missing_local_node(self):
|
|
292
|
+
remote = Node.objects.create(
|
|
293
|
+
hostname="existing-manager",
|
|
294
|
+
address="10.0.0.2",
|
|
295
|
+
port=9001,
|
|
296
|
+
mac_address="22:33:44:55:66:77",
|
|
297
|
+
)
|
|
298
|
+
charger = Charger(charger_id="NOLOCAL", manager_node=remote)
|
|
299
|
+
|
|
300
|
+
with patch.object(Node, "get_local", return_value=None):
|
|
301
|
+
result = charger.refresh_manager_node()
|
|
302
|
+
|
|
303
|
+
self.assertIsNone(result)
|
|
304
|
+
self.assertEqual(charger.manager_node, remote)
|
|
305
|
+
|
|
306
|
+
|
|
221
307
|
class ChargerUrlFallbackTests(TestCase):
|
|
222
308
|
@override_settings(ALLOWED_HOSTS=["fallback.example", "10.0.0.0/8"])
|
|
223
309
|
def test_reference_created_when_site_missing(self):
|
|
@@ -2320,6 +2406,43 @@ class ChargerAdminTests(TestCase):
|
|
|
2320
2406
|
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2321
2407
|
store.clear_pending_calls(charger.charger_id)
|
|
2322
2408
|
|
|
2409
|
+
def test_reset_charger_action_skips_when_transaction_active(self):
|
|
2410
|
+
charger = Charger.objects.create(charger_id="RESETADMIN")
|
|
2411
|
+
|
|
2412
|
+
class DummyConnection:
|
|
2413
|
+
def __init__(self):
|
|
2414
|
+
self.sent: list[str] = []
|
|
2415
|
+
|
|
2416
|
+
async def send(self, message):
|
|
2417
|
+
self.sent.append(message)
|
|
2418
|
+
|
|
2419
|
+
ws = DummyConnection()
|
|
2420
|
+
store.set_connection(charger.charger_id, charger.connector_id, ws)
|
|
2421
|
+
tx_obj = Transaction.objects.create(
|
|
2422
|
+
charger=charger,
|
|
2423
|
+
connector_id=charger.connector_id,
|
|
2424
|
+
start_time=timezone.now(),
|
|
2425
|
+
)
|
|
2426
|
+
store.set_transaction(charger.charger_id, charger.connector_id, tx_obj)
|
|
2427
|
+
try:
|
|
2428
|
+
url = reverse("admin:ocpp_charger_changelist")
|
|
2429
|
+
response = self.client.post(
|
|
2430
|
+
url,
|
|
2431
|
+
{
|
|
2432
|
+
"action": "reset_chargers",
|
|
2433
|
+
"index": 0,
|
|
2434
|
+
"select_across": 0,
|
|
2435
|
+
"_selected_action": [charger.pk],
|
|
2436
|
+
},
|
|
2437
|
+
follow=True,
|
|
2438
|
+
)
|
|
2439
|
+
self.assertEqual(response.status_code, 200)
|
|
2440
|
+
self.assertFalse(ws.sent)
|
|
2441
|
+
self.assertContains(response, "stop the session first")
|
|
2442
|
+
finally:
|
|
2443
|
+
store.pop_connection(charger.charger_id, charger.connector_id)
|
|
2444
|
+
store.pop_transaction(charger.charger_id, charger.connector_id)
|
|
2445
|
+
|
|
2323
2446
|
def test_admin_log_view_displays_entries(self):
|
|
2324
2447
|
charger = Charger.objects.create(charger_id="LOG2")
|
|
2325
2448
|
log_id = store.identity_key(charger.charger_id, charger.connector_id)
|
ocpp/views.py
CHANGED
|
@@ -1609,6 +1609,15 @@ def charger_session_search(request, cid, connector=None):
|
|
|
1609
1609
|
transactions = qs.order_by("-start_time")
|
|
1610
1610
|
except ValueError:
|
|
1611
1611
|
transactions = []
|
|
1612
|
+
if transactions is not None:
|
|
1613
|
+
transactions = list(transactions)
|
|
1614
|
+
rfid_cache: dict[str, dict[str, str | None]] = {}
|
|
1615
|
+
for tx in transactions:
|
|
1616
|
+
details = _transaction_rfid_details(tx, cache=rfid_cache)
|
|
1617
|
+
label_value = None
|
|
1618
|
+
if details:
|
|
1619
|
+
label_value = str(details.get("label") or "").strip() or None
|
|
1620
|
+
tx.rfid_label = label_value
|
|
1612
1621
|
overview = _connector_overview(charger, request.user)
|
|
1613
1622
|
connector_links = [
|
|
1614
1623
|
{
|
|
@@ -1734,7 +1743,7 @@ def charger_log_page(request, cid, connector=None):
|
|
|
1734
1743
|
@csrf_exempt
|
|
1735
1744
|
@api_login_required
|
|
1736
1745
|
def dispatch_action(request, cid, connector=None):
|
|
1737
|
-
connector_value,
|
|
1746
|
+
connector_value, _normalized_slug = _normalize_connector_slug(connector)
|
|
1738
1747
|
log_key = store.identity_key(cid, connector_value)
|
|
1739
1748
|
if connector_value is None:
|
|
1740
1749
|
charger_obj = (
|
|
@@ -1750,11 +1759,11 @@ def dispatch_action(request, cid, connector=None):
|
|
|
1750
1759
|
)
|
|
1751
1760
|
if charger_obj is None:
|
|
1752
1761
|
if connector_value is None:
|
|
1753
|
-
charger_obj,
|
|
1762
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1754
1763
|
charger_id=cid, connector_id=None
|
|
1755
1764
|
)
|
|
1756
1765
|
else:
|
|
1757
|
-
charger_obj,
|
|
1766
|
+
charger_obj, _created = Charger.objects.get_or_create(
|
|
1758
1767
|
charger_id=cid, connector_id=connector_value
|
|
1759
1768
|
)
|
|
1760
1769
|
|
|
@@ -1925,6 +1934,13 @@ def dispatch_action(request, cid, connector=None):
|
|
|
1925
1934
|
},
|
|
1926
1935
|
)
|
|
1927
1936
|
elif action == "reset":
|
|
1937
|
+
tx_obj = store.get_transaction(cid, connector_value)
|
|
1938
|
+
if tx_obj is not None:
|
|
1939
|
+
detail = _(
|
|
1940
|
+
"Reset is blocked while a charging session is active. "
|
|
1941
|
+
"Stop the session first."
|
|
1942
|
+
)
|
|
1943
|
+
return JsonResponse({"detail": detail}, status=409)
|
|
1928
1944
|
message_id = uuid.uuid4().hex
|
|
1929
1945
|
ocpp_action = "Reset"
|
|
1930
1946
|
expected_statuses = CALL_EXPECTED_STATUSES.get(ocpp_action)
|
pages/tests.py
CHANGED
|
@@ -109,6 +109,7 @@ from nodes.models import (
|
|
|
109
109
|
NodeRole,
|
|
110
110
|
NodeFeature,
|
|
111
111
|
NodeFeatureAssignment,
|
|
112
|
+
NetMessage,
|
|
112
113
|
)
|
|
113
114
|
from django.contrib.auth.models import AnonymousUser
|
|
114
115
|
|
|
@@ -721,18 +722,36 @@ class AdminDashboardAppListTests(TestCase):
|
|
|
721
722
|
|
|
722
723
|
def test_horologia_hidden_without_celery_feature(self):
|
|
723
724
|
resp = self.client.get(reverse("admin:index"))
|
|
724
|
-
self.assertNotContains(resp, "5. Horologia
|
|
725
|
+
self.assertNotContains(resp, "5. Horologia</a>")
|
|
725
726
|
|
|
726
727
|
def test_horologia_visible_with_celery_feature(self):
|
|
727
728
|
feature = NodeFeature.objects.create(slug="celery-queue", display="Celery Queue")
|
|
728
729
|
NodeFeatureAssignment.objects.create(node=self.node, feature=feature)
|
|
729
730
|
resp = self.client.get(reverse("admin:index"))
|
|
730
|
-
self.assertContains(resp, "5. Horologia
|
|
731
|
+
self.assertContains(resp, "5. Horologia</a>")
|
|
731
732
|
|
|
732
733
|
def test_horologia_visible_with_celery_lock(self):
|
|
733
734
|
self.celery_lock.write_text("")
|
|
734
735
|
resp = self.client.get(reverse("admin:index"))
|
|
735
|
-
self.assertContains(resp, "5. Horologia
|
|
736
|
+
self.assertContains(resp, "5. Horologia</a>")
|
|
737
|
+
|
|
738
|
+
def test_dashboard_shows_last_net_message(self):
|
|
739
|
+
NetMessage.objects.all().delete()
|
|
740
|
+
NetMessage.objects.create(subject="Older", body="First body")
|
|
741
|
+
NetMessage.objects.create(subject="Latest", body="Signal ready")
|
|
742
|
+
|
|
743
|
+
resp = self.client.get(reverse("admin:index"))
|
|
744
|
+
|
|
745
|
+
self.assertContains(resp, gettext("Net message"))
|
|
746
|
+
self.assertContains(resp, "Latest — Signal ready")
|
|
747
|
+
self.assertNotContains(resp, gettext("No net messages available"))
|
|
748
|
+
|
|
749
|
+
def test_dashboard_shows_placeholder_without_net_message(self):
|
|
750
|
+
NetMessage.objects.all().delete()
|
|
751
|
+
|
|
752
|
+
resp = self.client.get(reverse("admin:index"))
|
|
753
|
+
|
|
754
|
+
self.assertContains(resp, gettext("No net messages available"))
|
|
736
755
|
|
|
737
756
|
class AdminSidebarTests(TestCase):
|
|
738
757
|
def setUp(self):
|
|
@@ -2083,7 +2102,7 @@ class ControlNavTests(TestCase):
|
|
|
2083
2102
|
|
|
2084
2103
|
def test_readme_pill_visible(self):
|
|
2085
2104
|
resp = self.client.get(reverse("pages:readme"))
|
|
2086
|
-
self.assertContains(resp, 'href="/read/"')
|
|
2105
|
+
self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
|
|
2087
2106
|
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
2088
2107
|
|
|
2089
2108
|
def test_cookbook_pill_has_no_dropdown(self):
|
|
@@ -2099,7 +2118,7 @@ class ControlNavTests(TestCase):
|
|
|
2099
2118
|
|
|
2100
2119
|
self.assertContains(
|
|
2101
2120
|
resp,
|
|
2102
|
-
'<a class="nav-link" href="/read/"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
|
|
2121
|
+
'<a class="nav-link" href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"><span class="badge rounded-pill text-bg-secondary">COOKBOOKS</span></a>',
|
|
2103
2122
|
html=True,
|
|
2104
2123
|
)
|
|
2105
2124
|
self.assertNotContains(resp, 'dropdown-item" href="/man/"')
|
|
@@ -2205,7 +2224,7 @@ class SatelliteNavTests(TestCase):
|
|
|
2205
2224
|
|
|
2206
2225
|
def test_readme_pill_visible(self):
|
|
2207
2226
|
resp = self.client.get(reverse("pages:readme"))
|
|
2208
|
-
self.assertContains(resp, 'href="/read/"')
|
|
2227
|
+
self.assertContains(resp, 'href="/read/docs/cookbooks/install-start-stop-upgrade-uninstall"')
|
|
2209
2228
|
self.assertContains(resp, 'badge rounded-pill text-bg-secondary">COOKBOOKS')
|
|
2210
2229
|
|
|
2211
2230
|
|
pages/urls.py
CHANGED
|
@@ -17,6 +17,11 @@ urlpatterns = [
|
|
|
17
17
|
path("sitemap.xml", views.sitemap, name="pages-sitemap"),
|
|
18
18
|
path("release/", views.release_admin_redirect, name="release-admin"),
|
|
19
19
|
path("client-report/", views.client_report, name="client-report"),
|
|
20
|
+
path(
|
|
21
|
+
"client-report/download/<int:report_id>/",
|
|
22
|
+
views.client_report_download,
|
|
23
|
+
name="client-report-download",
|
|
24
|
+
),
|
|
20
25
|
path("release-checklist", views.release_checklist, name="release-checklist"),
|
|
21
26
|
path("login/", views.login_view, name="login"),
|
|
22
27
|
path("authenticator/setup/", views.authenticator_setup, name="authenticator-setup"),
|