arthexis 0.1.22__py3-none-any.whl → 0.1.24__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.

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
@@ -215,6 +232,13 @@ class Charger(Entity):
215
232
  "Latest GetConfiguration response received from this charge point."
216
233
  ),
217
234
  )
235
+ node_origin = models.ForeignKey(
236
+ "nodes.Node",
237
+ on_delete=models.SET_NULL,
238
+ null=True,
239
+ blank=True,
240
+ related_name="origin_chargers",
241
+ )
218
242
  manager_node = models.ForeignKey(
219
243
  "nodes.Node",
220
244
  on_delete=models.SET_NULL,
@@ -222,6 +246,9 @@ class Charger(Entity):
222
246
  blank=True,
223
247
  related_name="managed_chargers",
224
248
  )
249
+ allow_remote = models.BooleanField(default=False)
250
+ export_transactions = models.BooleanField(default=False)
251
+ last_online_at = models.DateTimeField(null=True, blank=True)
225
252
  owner_users = models.ManyToManyField(
226
253
  settings.AUTH_USER_MODEL,
227
254
  blank=True,
@@ -276,6 +303,24 @@ class Charger(Entity):
276
303
  user_group_ids = user.groups.values_list("pk", flat=True)
277
304
  return self.owner_groups.filter(pk__in=user_group_ids).exists()
278
305
 
306
+ @property
307
+ def is_local(self) -> bool:
308
+ """Return ``True`` when this charger originates from the local node."""
309
+
310
+ local = Node.get_local()
311
+ if not local:
312
+ return False
313
+ if self.node_origin_id is None:
314
+ return True
315
+ return self.node_origin_id == local.pk
316
+
317
+ def save(self, *args, **kwargs):
318
+ if self.node_origin_id is None:
319
+ local = Node.get_local()
320
+ if local:
321
+ self.node_origin = local
322
+ super().save(*args, **kwargs)
323
+
279
324
  class Meta:
280
325
  verbose_name = _("Charge Point")
281
326
  verbose_name_plural = _("Charge Points")
@@ -481,11 +526,17 @@ class Charger(Entity):
481
526
  ref_value = self._full_url()
482
527
  if url_targets_local_loopback(ref_value):
483
528
  return
484
- if not self.reference or self.reference.value != ref_value:
529
+ if not self.reference:
485
530
  self.reference = Reference.objects.create(
486
531
  value=ref_value, alt_text=self.charger_id
487
532
  )
488
533
  super().save(update_fields=["reference"])
534
+ elif self.reference.value != ref_value:
535
+ Reference.objects.filter(pk=self.reference_id).update(
536
+ value=ref_value, alt_text=self.charger_id
537
+ )
538
+ self.reference.value = ref_value
539
+ self.reference.alt_text = self.charger_id
489
540
 
490
541
  def refresh_manager_node(self, node: Node | None = None) -> Node | None:
491
542
  """Ensure ``manager_node`` matches the provided or local node."""
@@ -783,7 +834,11 @@ class Transaction(Entity):
783
834
  def vehicle_identifier(self) -> str:
784
835
  """Return the preferred vehicle identifier for this transaction."""
785
836
 
786
- return (self.vid or self.vin or "").strip()
837
+ vid = (self.vid or "").strip()
838
+ if vid:
839
+ return vid
840
+
841
+ return (self.vin or "").strip()
787
842
 
788
843
  @property
789
844
  def vehicle_identifier_source(self) -> str:
ocpp/tasks.py CHANGED
@@ -1,7 +1,9 @@
1
+ import base64
1
2
  import json
2
3
  import logging
3
4
  import uuid
4
5
  from datetime import date, datetime, time, timedelta
6
+ from decimal import Decimal, InvalidOperation
5
7
  from pathlib import Path
6
8
 
7
9
  from asgiref.sync import async_to_sync
@@ -9,17 +11,259 @@ from celery import shared_task
9
11
  from django.conf import settings
10
12
  from django.contrib.auth import get_user_model
11
13
  from django.utils import timezone
14
+ from django.utils.dateparse import parse_datetime
12
15
  from django.db.models import Q
16
+ import requests
17
+ from requests import RequestException
18
+ from cryptography.hazmat.primitives import hashes
19
+ from cryptography.hazmat.primitives.asymmetric import padding
13
20
 
14
21
  from core import mailer
15
22
  from nodes.models import Node
16
23
 
17
24
  from . import store
18
- from .models import Charger, MeterValue, Transaction
25
+ from .models import Charger, Location, MeterValue, Transaction
19
26
 
20
27
  logger = logging.getLogger(__name__)
21
28
 
22
29
 
30
+ def _sign_payload(payload_json: str, private_key) -> str | None:
31
+ if not private_key:
32
+ return None
33
+ try:
34
+ signature = private_key.sign(
35
+ payload_json.encode(),
36
+ padding.PKCS1v15(),
37
+ hashes.SHA256(),
38
+ )
39
+ except Exception:
40
+ return None
41
+ return base64.b64encode(signature).decode()
42
+
43
+
44
+ def _parse_remote_datetime(value):
45
+ if not value:
46
+ return None
47
+ dt = parse_datetime(value)
48
+ if dt is None:
49
+ return None
50
+ if timezone.is_naive(dt):
51
+ dt = timezone.make_aware(dt, timezone.get_current_timezone())
52
+ return dt
53
+
54
+
55
+ def _to_decimal(value):
56
+ if value in (None, ""):
57
+ return None
58
+ try:
59
+ return Decimal(str(value))
60
+ except (InvalidOperation, TypeError, ValueError):
61
+ return None
62
+
63
+
64
+ def _apply_remote_charger_payload(node, payload: dict) -> Charger | None:
65
+ serial = Charger.normalize_serial(payload.get("charger_id"))
66
+ if not serial or Charger.is_placeholder_serial(serial):
67
+ return None
68
+
69
+ connector = payload.get("connector_id")
70
+ if connector in (None, ""):
71
+ connector_value = None
72
+ elif isinstance(connector, int):
73
+ connector_value = connector
74
+ else:
75
+ try:
76
+ connector_value = int(str(connector))
77
+ except (TypeError, ValueError):
78
+ connector_value = None
79
+
80
+ charger = Charger.objects.filter(
81
+ charger_id=serial, connector_id=connector_value
82
+ ).first()
83
+ if not charger:
84
+ return None
85
+
86
+ location_payload = payload.get("location")
87
+ location_obj = None
88
+ if isinstance(location_payload, dict):
89
+ name = location_payload.get("name")
90
+ if name:
91
+ location_obj, _ = Location.objects.get_or_create(name=name)
92
+ for field in ("latitude", "longitude", "zone", "contract_type"):
93
+ setattr(location_obj, field, location_payload.get(field))
94
+ location_obj.save()
95
+
96
+ datetime_fields = [
97
+ "firmware_timestamp",
98
+ "last_heartbeat",
99
+ "availability_state_updated_at",
100
+ "availability_requested_at",
101
+ "availability_request_status_at",
102
+ "diagnostics_timestamp",
103
+ "last_status_timestamp",
104
+ ]
105
+
106
+ updates: dict[str, object] = {
107
+ "node_origin": node,
108
+ "allow_remote": bool(payload.get("allow_remote", False)),
109
+ "export_transactions": bool(payload.get("export_transactions", False)),
110
+ "last_online_at": timezone.now(),
111
+ }
112
+
113
+ simple_fields = [
114
+ "display_name",
115
+ "language",
116
+ "public_display",
117
+ "require_rfid",
118
+ "firmware_status",
119
+ "firmware_status_info",
120
+ "last_status",
121
+ "last_error_code",
122
+ "last_status_vendor_info",
123
+ "availability_state",
124
+ "availability_requested_state",
125
+ "availability_request_status",
126
+ "availability_request_details",
127
+ "temperature",
128
+ "temperature_unit",
129
+ "diagnostics_status",
130
+ "diagnostics_location",
131
+ ]
132
+
133
+ for field in simple_fields:
134
+ updates[field] = payload.get(field)
135
+
136
+ if location_obj is not None:
137
+ updates["location"] = location_obj
138
+
139
+ for field in datetime_fields:
140
+ updates[field] = _parse_remote_datetime(payload.get(field))
141
+
142
+ updates["last_meter_values"] = payload.get("last_meter_values") or {}
143
+
144
+ Charger.objects.filter(pk=charger.pk).update(**updates)
145
+ charger.refresh_from_db()
146
+ return charger
147
+
148
+
149
+ def _sync_transactions_payload(payload: dict) -> int:
150
+ if not isinstance(payload, dict):
151
+ return 0
152
+
153
+ chargers_map: dict[tuple[str, int | None], Charger] = {}
154
+ for entry in payload.get("chargers", []):
155
+ serial = Charger.normalize_serial(entry.get("charger_id"))
156
+ if not serial or Charger.is_placeholder_serial(serial):
157
+ continue
158
+ connector = entry.get("connector_id")
159
+ if connector in (None, ""):
160
+ connector_value = None
161
+ elif isinstance(connector, int):
162
+ connector_value = connector
163
+ else:
164
+ try:
165
+ connector_value = int(str(connector))
166
+ except (TypeError, ValueError):
167
+ connector_value = None
168
+ charger = Charger.objects.filter(
169
+ charger_id=serial, connector_id=connector_value
170
+ ).first()
171
+ if charger:
172
+ chargers_map[(serial, connector_value)] = charger
173
+
174
+ imported = 0
175
+ for tx in payload.get("transactions", []):
176
+ if not isinstance(tx, dict):
177
+ continue
178
+ serial = Charger.normalize_serial(tx.get("charger"))
179
+ if not serial:
180
+ continue
181
+ connector = tx.get("connector_id")
182
+ if connector in (None, ""):
183
+ connector_value = None
184
+ elif isinstance(connector, int):
185
+ connector_value = connector
186
+ else:
187
+ try:
188
+ connector_value = int(str(connector))
189
+ except (TypeError, ValueError):
190
+ connector_value = None
191
+
192
+ charger = chargers_map.get((serial, connector_value))
193
+ if charger is None:
194
+ charger = chargers_map.get((serial, None))
195
+ if charger is None:
196
+ continue
197
+
198
+ start_time = _parse_remote_datetime(tx.get("start_time"))
199
+ if start_time is None:
200
+ continue
201
+
202
+ defaults = {
203
+ "connector_id": connector_value,
204
+ "account_id": tx.get("account"),
205
+ "rfid": tx.get("rfid", ""),
206
+ "vid": tx.get("vid", ""),
207
+ "vin": tx.get("vin", ""),
208
+ "meter_start": tx.get("meter_start"),
209
+ "meter_stop": tx.get("meter_stop"),
210
+ "voltage_start": _to_decimal(tx.get("voltage_start")),
211
+ "voltage_stop": _to_decimal(tx.get("voltage_stop")),
212
+ "current_import_start": _to_decimal(tx.get("current_import_start")),
213
+ "current_import_stop": _to_decimal(tx.get("current_import_stop")),
214
+ "current_offered_start": _to_decimal(tx.get("current_offered_start")),
215
+ "current_offered_stop": _to_decimal(tx.get("current_offered_stop")),
216
+ "temperature_start": _to_decimal(tx.get("temperature_start")),
217
+ "temperature_stop": _to_decimal(tx.get("temperature_stop")),
218
+ "soc_start": _to_decimal(tx.get("soc_start")),
219
+ "soc_stop": _to_decimal(tx.get("soc_stop")),
220
+ "stop_time": _parse_remote_datetime(tx.get("stop_time")),
221
+ "received_start_time": _parse_remote_datetime(
222
+ tx.get("received_start_time")
223
+ ),
224
+ "received_stop_time": _parse_remote_datetime(
225
+ tx.get("received_stop_time")
226
+ ),
227
+ }
228
+
229
+ transaction, _ = Transaction.objects.update_or_create(
230
+ charger=charger,
231
+ start_time=start_time,
232
+ defaults=defaults,
233
+ )
234
+ transaction.meter_values.all().delete()
235
+ for mv in tx.get("meter_values", []):
236
+ if not isinstance(mv, dict):
237
+ continue
238
+ timestamp = _parse_remote_datetime(mv.get("timestamp"))
239
+ if timestamp is None:
240
+ continue
241
+ connector_mv = mv.get("connector_id")
242
+ if connector_mv in (None, ""):
243
+ connector_mv = None
244
+ elif isinstance(connector_mv, str):
245
+ try:
246
+ connector_mv = int(connector_mv)
247
+ except (TypeError, ValueError):
248
+ connector_mv = None
249
+ MeterValue.objects.create(
250
+ charger=charger,
251
+ transaction=transaction,
252
+ connector_id=connector_mv,
253
+ timestamp=timestamp,
254
+ context=mv.get("context", ""),
255
+ energy=_to_decimal(mv.get("energy")),
256
+ voltage=_to_decimal(mv.get("voltage")),
257
+ current_import=_to_decimal(mv.get("current_import")),
258
+ current_offered=_to_decimal(mv.get("current_offered")),
259
+ temperature=_to_decimal(mv.get("temperature")),
260
+ soc=_to_decimal(mv.get("soc")),
261
+ )
262
+ imported += 1
263
+
264
+ return imported
265
+
266
+
23
267
  @shared_task
24
268
  def check_charge_point_configuration(charger_pk: int) -> bool:
25
269
  """Request the latest configuration from a connected charge point."""
@@ -135,6 +379,97 @@ def purge_meter_values() -> int:
135
379
  purge_meter_readings = purge_meter_values
136
380
 
137
381
 
382
+ @shared_task
383
+ def sync_remote_chargers() -> int:
384
+ """Synchronize remote charger metadata and transactions."""
385
+
386
+ local = Node.get_local()
387
+ if not local:
388
+ logger.debug("Remote sync skipped: local node not registered")
389
+ return 0
390
+
391
+ private_key = local.get_private_key()
392
+ if private_key is None:
393
+ logger.warning("Remote sync skipped: missing local node private key")
394
+ return 0
395
+
396
+ chargers = (
397
+ Charger.objects.filter(export_transactions=True)
398
+ .exclude(node_origin__isnull=True)
399
+ .select_related("node_origin")
400
+ )
401
+ if local.pk:
402
+ chargers = chargers.exclude(node_origin=local)
403
+
404
+ grouped: dict[Node, list[Charger]] = {}
405
+ for charger in chargers:
406
+ origin = charger.node_origin
407
+ if not origin:
408
+ continue
409
+ grouped.setdefault(origin, []).append(charger)
410
+
411
+ if not grouped:
412
+ return 0
413
+
414
+ synced = 0
415
+ for node, node_chargers in grouped.items():
416
+ payload = {
417
+ "requester": str(local.uuid),
418
+ "include_transactions": True,
419
+ "chargers": [],
420
+ }
421
+ for charger in node_chargers:
422
+ payload["chargers"].append(
423
+ {
424
+ "charger_id": charger.charger_id,
425
+ "connector_id": charger.connector_id,
426
+ "since": charger.last_online_at.isoformat()
427
+ if charger.last_online_at
428
+ else None,
429
+ }
430
+ )
431
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
432
+ signature = _sign_payload(payload_json, private_key)
433
+ headers = {"Content-Type": "application/json"}
434
+ if signature:
435
+ headers["X-Signature"] = signature
436
+
437
+ url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
438
+ try:
439
+ response = requests.post(url, data=payload_json, headers=headers, timeout=5)
440
+ except RequestException as exc:
441
+ logger.warning("Failed to sync chargers from %s: %s", node, exc)
442
+ continue
443
+
444
+ if not response.ok:
445
+ logger.warning(
446
+ "Sync request to %s returned %s", node, response.status_code
447
+ )
448
+ continue
449
+
450
+ try:
451
+ data = response.json()
452
+ except ValueError:
453
+ logger.warning("Invalid JSON payload received from %s", node)
454
+ continue
455
+
456
+ chargers_payload = data.get("chargers", [])
457
+ for entry in chargers_payload:
458
+ charger = _apply_remote_charger_payload(node, entry)
459
+ if charger:
460
+ synced += 1
461
+
462
+ transactions_payload = data.get("transactions")
463
+ if transactions_payload:
464
+ imported = _sync_transactions_payload(transactions_payload)
465
+ if imported:
466
+ logger.info(
467
+ "Imported %s transaction(s) from node %s", imported, node
468
+ )
469
+
470
+ return synced
471
+
472
+
138
473
  def _resolve_report_window() -> tuple[datetime, datetime, date]:
139
474
  """Return the start/end datetimes for today's reporting window."""
140
475
 
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, _ = _normalize_connector_slug(connector)
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, _ = Charger.objects.get_or_create(
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, _ = Charger.objects.get_or_create(
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)