arthexis 0.1.24__py3-none-any.whl → 0.1.25__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/views.py CHANGED
@@ -37,12 +37,23 @@ from django.db.models import Q
37
37
  from core.models import RFID
38
38
  from ocpp import store
39
39
  from ocpp.models import Charger
40
+ from ocpp.network import (
41
+ apply_remote_charger_payload,
42
+ serialize_charger_for_network,
43
+ sync_transactions_payload,
44
+ )
40
45
  from ocpp.transactions_io import export_transactions
41
46
  from asgiref.sync import async_to_sync
42
47
 
43
48
  from .rfid_sync import apply_rfid_payload, serialize_rfid
44
49
 
45
- from .models import Node, NetMessage, PendingNetMessage, node_information_updated
50
+ from .models import (
51
+ Node,
52
+ NetMessage,
53
+ PendingNetMessage,
54
+ NodeRole,
55
+ node_information_updated,
56
+ )
46
57
  from .utils import capture_screenshot, save_screenshot
47
58
 
48
59
 
@@ -175,65 +186,6 @@ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
175
186
  user.user_permissions.set(perm_objs)
176
187
 
177
188
 
178
- def _serialize_charger_for_network(charger: Charger) -> dict[str, object]:
179
- simple_fields = [
180
- "display_name",
181
- "language",
182
- "public_display",
183
- "require_rfid",
184
- "firmware_status",
185
- "firmware_status_info",
186
- "last_status",
187
- "last_error_code",
188
- "last_status_vendor_info",
189
- "availability_state",
190
- "availability_requested_state",
191
- "availability_request_status",
192
- "availability_request_details",
193
- "temperature",
194
- "temperature_unit",
195
- "diagnostics_status",
196
- "diagnostics_location",
197
- ]
198
- datetime_fields = [
199
- "firmware_timestamp",
200
- "last_heartbeat",
201
- "availability_state_updated_at",
202
- "availability_requested_at",
203
- "availability_request_status_at",
204
- "diagnostics_timestamp",
205
- "last_status_timestamp",
206
- "last_online_at",
207
- ]
208
-
209
- data: dict[str, object] = {
210
- "charger_id": charger.charger_id,
211
- "connector_id": charger.connector_id,
212
- "allow_remote": charger.allow_remote,
213
- "export_transactions": charger.export_transactions,
214
- "last_meter_values": charger.last_meter_values or {},
215
- }
216
-
217
- for field in simple_fields:
218
- data[field] = getattr(charger, field)
219
-
220
- for field in datetime_fields:
221
- value = getattr(charger, field)
222
- data[field] = value.isoformat() if value else None
223
-
224
- if charger.location:
225
- location = charger.location
226
- data["location"] = {
227
- "name": location.name,
228
- "latitude": location.latitude,
229
- "longitude": location.longitude,
230
- "zone": location.zone,
231
- "contract_type": location.contract_type,
232
- }
233
-
234
- return data
235
-
236
-
237
189
  def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
238
190
  if not isinstance(values, list):
239
191
  return []
@@ -410,7 +362,7 @@ def _get_advertised_address(request, node) -> str:
410
362
  host_ip = _get_host_ip(request)
411
363
  if host_ip:
412
364
  return host_ip
413
- return node.address
365
+ return node.get_primary_contact() or node.address or node.hostname
414
366
 
415
367
 
416
368
  @api_login_required
@@ -420,7 +372,10 @@ def node_list(request):
420
372
  nodes = [
421
373
  {
422
374
  "hostname": node.hostname,
375
+ "network_hostname": node.network_hostname,
423
376
  "address": node.address,
377
+ "ipv4_address": node.ipv4_address,
378
+ "ipv6_address": node.ipv6_address,
424
379
  "port": node.port,
425
380
  "last_seen": node.last_seen,
426
381
  "features": list(node.features.values_list("slug", flat=True)),
@@ -448,21 +403,22 @@ def node_info(request):
448
403
  advertised_port = host_port
449
404
  if host_domain:
450
405
  hostname = host_domain
451
- if advertised_address and advertised_address != node.address:
452
- address = advertised_address
453
- else:
454
- address = host_domain
406
+ address = advertised_address or host_domain
455
407
  else:
456
408
  hostname = node.hostname
457
- address = advertised_address
409
+ address = advertised_address or node.address or node.network_hostname or ""
458
410
  data = {
459
411
  "hostname": hostname,
412
+ "network_hostname": node.network_hostname,
460
413
  "address": address,
414
+ "ipv4_address": node.ipv4_address,
415
+ "ipv6_address": node.ipv6_address,
461
416
  "port": advertised_port,
462
417
  "mac_address": node.mac_address,
463
418
  "public_key": node.public_key,
464
419
  "features": list(node.features.values_list("slug", flat=True)),
465
420
  "role": node.role.name if node.role_id else "",
421
+ "contact_hosts": node.get_remote_host_candidates(),
466
422
  }
467
423
 
468
424
  if token:
@@ -506,7 +462,14 @@ def _add_cors_headers(request, response):
506
462
  def _node_display_name(node: Node) -> str:
507
463
  """Return a human-friendly name for ``node`` suitable for messaging."""
508
464
 
509
- for attr in ("hostname", "public_endpoint", "address"):
465
+ for attr in (
466
+ "hostname",
467
+ "network_hostname",
468
+ "public_endpoint",
469
+ "address",
470
+ "ipv6_address",
471
+ "ipv4_address",
472
+ ):
510
473
  value = getattr(node, attr, "") or ""
511
474
  value = value.strip()
512
475
  if value:
@@ -558,10 +521,13 @@ def register_node(request):
558
521
  else:
559
522
  features = data.get("features")
560
523
 
561
- hostname = data.get("hostname")
562
- address = data.get("address")
524
+ hostname = (data.get("hostname") or "").strip()
525
+ address = (data.get("address") or "").strip()
526
+ network_hostname = (data.get("network_hostname") or "").strip()
527
+ ipv4_address = (data.get("ipv4_address") or "").strip()
528
+ ipv6_address = (data.get("ipv6_address") or "").strip()
563
529
  port = data.get("port", 8000)
564
- mac_address = data.get("mac_address")
530
+ mac_address = (data.get("mac_address") or "").strip()
565
531
  public_key = data.get("public_key")
566
532
  token = data.get("token")
567
533
  signature = data.get("signature")
@@ -577,12 +543,27 @@ def register_node(request):
577
543
  Node.normalize_relation(raw_relation) if relation_present else None
578
544
  )
579
545
 
580
- if not hostname or not address or not mac_address:
546
+ if not hostname or not mac_address:
547
+ response = JsonResponse(
548
+ {"detail": "hostname and mac_address required"}, status=400
549
+ )
550
+ return _add_cors_headers(request, response)
551
+
552
+ if not any([address, network_hostname, ipv4_address, ipv6_address]):
581
553
  response = JsonResponse(
582
- {"detail": "hostname, address and mac_address required"}, status=400
554
+ {
555
+ "detail": "at least one of address, network_hostname, "
556
+ "ipv4_address or ipv6_address must be provided",
557
+ },
558
+ status=400,
583
559
  )
584
560
  return _add_cors_headers(request, response)
585
561
 
562
+ try:
563
+ port = int(port)
564
+ except (TypeError, ValueError):
565
+ port = 8000
566
+
586
567
  verified = False
587
568
  if public_key and token and signature:
588
569
  try:
@@ -603,9 +584,15 @@ def register_node(request):
603
584
  return _add_cors_headers(request, response)
604
585
 
605
586
  mac_address = mac_address.lower()
587
+ address_value = address or None
588
+ ipv4_value = ipv4_address or None
589
+ ipv6_value = ipv6_address or None
606
590
  defaults = {
607
591
  "hostname": hostname,
608
- "address": address,
592
+ "network_hostname": network_hostname,
593
+ "address": address_value,
594
+ "ipv4_address": ipv4_value,
595
+ "ipv6_address": ipv6_value,
609
596
  "port": port,
610
597
  }
611
598
  role_name = str(data.get("role") or data.get("role_name") or "").strip()
@@ -630,10 +617,18 @@ def register_node(request):
630
617
  if not created:
631
618
  previous_version = (node.installed_version or "").strip()
632
619
  previous_revision = (node.installed_revision or "").strip()
633
- node.hostname = hostname
634
- node.address = address
635
- node.port = port
636
- update_fields = ["hostname", "address", "port"]
620
+ update_fields = []
621
+ for field, value in (
622
+ ("hostname", hostname),
623
+ ("network_hostname", network_hostname),
624
+ ("address", address_value),
625
+ ("ipv4_address", ipv4_value),
626
+ ("ipv6_address", ipv6_value),
627
+ ("port", port),
628
+ ):
629
+ if getattr(node, field) != value:
630
+ setattr(node, field, value)
631
+ update_fields.append(field)
637
632
  if verified:
638
633
  node.public_key = public_key
639
634
  update_fields.append("public_key")
@@ -651,7 +646,8 @@ def register_node(request):
651
646
  if desired_role and node.role_id != desired_role.id:
652
647
  node.role = desired_role
653
648
  update_fields.append("role")
654
- node.save(update_fields=update_fields)
649
+ if update_fields:
650
+ node.save(update_fields=update_fields)
655
651
  current_version = (node.installed_version or "").strip()
656
652
  current_revision = (node.installed_revision or "").strip()
657
653
  node_information_updated.send(
@@ -866,7 +862,7 @@ def network_chargers(request):
866
862
  filters |= Q(charger_id=serial, connector_id=connector_value)
867
863
  qs = qs.filter(filters)
868
864
 
869
- chargers = [_serialize_charger_for_network(charger) for charger in qs]
865
+ chargers = [serialize_charger_for_network(charger) for charger in qs]
870
866
 
871
867
  include_transactions = bool(body.get("include_transactions"))
872
868
  response_data: dict[str, object] = {"chargers": chargers}
@@ -883,6 +879,55 @@ def network_chargers(request):
883
879
  return JsonResponse(response_data)
884
880
 
885
881
 
882
+ @csrf_exempt
883
+ def forward_chargers(request):
884
+ """Receive forwarded charger metadata and transactions from trusted peers."""
885
+
886
+ if request.method != "POST":
887
+ return JsonResponse({"detail": "POST required"}, status=405)
888
+
889
+ try:
890
+ body = json.loads(request.body.decode() or "{}")
891
+ except json.JSONDecodeError:
892
+ return JsonResponse({"detail": "invalid json"}, status=400)
893
+
894
+ requester = body.get("requester")
895
+ if not requester:
896
+ return JsonResponse({"detail": "requester required"}, status=400)
897
+
898
+ requester_mac = _clean_requester_hint(body.get("requester_mac"))
899
+ requester_public_key = _clean_requester_hint(
900
+ body.get("requester_public_key"), strip=False
901
+ )
902
+
903
+ node, error_response = _load_signed_node(
904
+ request,
905
+ requester,
906
+ mac_address=requester_mac,
907
+ public_key=requester_public_key,
908
+ )
909
+ if error_response is not None:
910
+ return error_response
911
+
912
+ processed = 0
913
+ chargers_payload = body.get("chargers", [])
914
+ if not isinstance(chargers_payload, list):
915
+ chargers_payload = []
916
+ for entry in chargers_payload:
917
+ if not isinstance(entry, Mapping):
918
+ continue
919
+ charger = apply_remote_charger_payload(node, entry)
920
+ if charger:
921
+ processed += 1
922
+
923
+ imported = 0
924
+ transactions_payload = body.get("transactions")
925
+ if isinstance(transactions_payload, Mapping):
926
+ imported = sync_transactions_payload(transactions_payload)
927
+
928
+ return JsonResponse({"status": "ok", "chargers": processed, "transactions": imported})
929
+
930
+
886
931
  def _require_local_origin(charger: Charger) -> bool:
887
932
  local = Node.get_local()
888
933
  if not local:
@@ -1561,7 +1606,10 @@ def public_node_endpoint(request, endpoint):
1561
1606
  if request.method == "GET":
1562
1607
  data = {
1563
1608
  "hostname": node.hostname,
1564
- "address": node.address,
1609
+ "network_hostname": node.network_hostname,
1610
+ "address": node.address or node.get_primary_contact(),
1611
+ "ipv4_address": node.ipv4_address,
1612
+ "ipv6_address": node.ipv6_address,
1565
1613
  "port": node.port,
1566
1614
  "badge_color": node.badge_color,
1567
1615
  "last_seen": node.last_seen,
ocpp/admin.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  from django.shortcuts import redirect
11
11
  from django.utils import formats, timezone, translation
12
+ from django.utils.translation import gettext_lazy as _
12
13
  from django.utils.dateparse import parse_datetime
13
14
  from django.utils.html import format_html
14
15
  from django.urls import path
@@ -21,6 +22,8 @@ import requests
21
22
  from requests import RequestException
22
23
  from cryptography.hazmat.primitives import hashes
23
24
  from cryptography.hazmat.primitives.asymmetric import padding
25
+ from django.db import transaction
26
+ from django.core.exceptions import ValidationError
24
27
 
25
28
  from .models import (
26
29
  Charger,
@@ -30,6 +33,7 @@ from .models import (
30
33
  Transaction,
31
34
  Location,
32
35
  DataTransferMessage,
36
+ CPReservation,
33
37
  )
34
38
  from .simulator import ChargePointSimulator
35
39
  from . import store
@@ -74,6 +78,43 @@ class TransactionImportForm(forms.Form):
74
78
  file = forms.FileField()
75
79
 
76
80
 
81
+ class CPReservationForm(forms.ModelForm):
82
+ class Meta:
83
+ model = CPReservation
84
+ fields = [
85
+ "location",
86
+ "account",
87
+ "rfid",
88
+ "id_tag",
89
+ "start_time",
90
+ "duration_minutes",
91
+ ]
92
+
93
+ def clean(self):
94
+ cleaned = super().clean()
95
+ instance = self.instance
96
+ for field in self.Meta.fields:
97
+ if field in cleaned:
98
+ setattr(instance, field, cleaned[field])
99
+ try:
100
+ instance.allocate_connector(force=bool(instance.pk))
101
+ except ValidationError as exc:
102
+ if exc.message_dict:
103
+ for field, errors in exc.message_dict.items():
104
+ for error in errors:
105
+ self.add_error(field, error)
106
+ raise forms.ValidationError(
107
+ _("Unable to allocate a connector for the selected time window.")
108
+ )
109
+ raise forms.ValidationError(exc.messages or [str(exc)])
110
+ if not instance.id_tag_value:
111
+ message = _("Select an RFID or provide an idTag for the reservation.")
112
+ self.add_error("id_tag", message)
113
+ self.add_error("rfid", message)
114
+ raise forms.ValidationError(message)
115
+ return cleaned
116
+
117
+
77
118
  class LogViewAdminMixin:
78
119
  """Mixin providing an admin view to display charger or simulator logs."""
79
120
 
@@ -218,6 +259,7 @@ class LocationAdmin(EntityModelAdmin):
218
259
  form = LocationAdminForm
219
260
  list_display = ("name", "zone", "contract_type", "latitude", "longitude")
220
261
  change_form_template = "admin/ocpp/location/change_form.html"
262
+ search_fields = ("name",)
221
263
 
222
264
 
223
265
  @admin.register(DataTransferMessage)
@@ -258,6 +300,130 @@ class DataTransferMessageAdmin(admin.ModelAdmin):
258
300
  )
259
301
 
260
302
 
303
+ @admin.register(CPReservation)
304
+ class CPReservationAdmin(EntityModelAdmin):
305
+ form = CPReservationForm
306
+ list_display = (
307
+ "location",
308
+ "connector_side_display",
309
+ "start_time",
310
+ "end_time_display",
311
+ "account",
312
+ "id_tag_display",
313
+ "evcs_status",
314
+ "evcs_confirmed",
315
+ )
316
+ list_filter = ("location", "evcs_confirmed")
317
+ search_fields = (
318
+ "location__name",
319
+ "connector__charger_id",
320
+ "connector__display_name",
321
+ "account__name",
322
+ "id_tag",
323
+ "rfid__rfid",
324
+ )
325
+ date_hierarchy = "start_time"
326
+ ordering = ("-start_time",)
327
+ autocomplete_fields = ("location", "account", "rfid")
328
+ readonly_fields = (
329
+ "connector_identity",
330
+ "connector_side_display",
331
+ "evcs_status",
332
+ "evcs_error",
333
+ "evcs_confirmed",
334
+ "evcs_confirmed_at",
335
+ "ocpp_message_id",
336
+ "created_on",
337
+ "updated_on",
338
+ )
339
+ fieldsets = (
340
+ (
341
+ None,
342
+ {
343
+ "fields": (
344
+ "location",
345
+ "account",
346
+ "rfid",
347
+ "id_tag",
348
+ "start_time",
349
+ "duration_minutes",
350
+ )
351
+ },
352
+ ),
353
+ (
354
+ _("Assigned connector"),
355
+ {"fields": ("connector_identity", "connector_side_display")},
356
+ ),
357
+ (
358
+ _("EVCS response"),
359
+ {
360
+ "fields": (
361
+ "evcs_confirmed",
362
+ "evcs_status",
363
+ "evcs_confirmed_at",
364
+ "evcs_error",
365
+ "ocpp_message_id",
366
+ )
367
+ },
368
+ ),
369
+ (
370
+ _("Metadata"),
371
+ {"fields": ("created_on", "updated_on")},
372
+ ),
373
+ )
374
+
375
+ def save_model(self, request, obj, form, change):
376
+ trigger_fields = {
377
+ "start_time",
378
+ "duration_minutes",
379
+ "location",
380
+ "id_tag",
381
+ "rfid",
382
+ "account",
383
+ }
384
+ changed_data = set(getattr(form, "changed_data", []))
385
+ should_send = not change or bool(trigger_fields.intersection(changed_data))
386
+ with transaction.atomic():
387
+ super().save_model(request, obj, form, change)
388
+ if should_send:
389
+ try:
390
+ obj.send_reservation_request()
391
+ except ValidationError as exc:
392
+ raise ValidationError(exc.message_dict or exc.messages or str(exc))
393
+ else:
394
+ self.message_user(
395
+ request,
396
+ _("Reservation request sent to %(connector)s.")
397
+ % {"connector": self.connector_identity(obj)},
398
+ messages.SUCCESS,
399
+ )
400
+
401
+ @admin.display(description=_("Connector"), ordering="connector__connector_id")
402
+ def connector_side_display(self, obj):
403
+ return obj.connector_label or "-"
404
+
405
+ @admin.display(description=_("Connector identity"))
406
+ def connector_identity(self, obj):
407
+ if obj.connector_id:
408
+ return obj.connector.identity_slug()
409
+ return "-"
410
+
411
+ @admin.display(description=_("End time"))
412
+ def end_time_display(self, obj):
413
+ try:
414
+ value = timezone.localtime(obj.end_time)
415
+ except Exception:
416
+ value = obj.end_time
417
+ if not value:
418
+ return "-"
419
+ return formats.date_format(value, "DATETIME_FORMAT")
420
+
421
+ @admin.display(description=_("Id tag"))
422
+ def id_tag_display(self, obj):
423
+ value = obj.id_tag_value
424
+ return value or "-"
425
+
426
+
261
427
  @admin.register(Charger)
262
428
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
263
429
  _REMOTE_DATETIME_FIELDS = {
@@ -327,6 +493,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
327
493
  "fields": (
328
494
  "node_origin",
329
495
  "manager_node",
496
+ "forwarded_to",
497
+ "forwarding_watermark",
330
498
  "allow_remote",
331
499
  "export_transactions",
332
500
  "last_online_at",
@@ -361,6 +529,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
361
529
  "availability_request_status_at",
362
530
  "availability_request_details",
363
531
  "configuration",
532
+ "forwarded_to",
533
+ "forwarding_watermark",
364
534
  "last_online_at",
365
535
  )
366
536
  list_display = (
@@ -429,7 +599,15 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
429
599
  )
430
600
  return False, {}
431
601
  origin = charger.node_origin
432
- if not origin.address or not origin.port:
602
+ if not origin.port:
603
+ self.message_user(
604
+ request,
605
+ f"{charger}: remote node port is not configured.",
606
+ level=messages.ERROR,
607
+ )
608
+ return False, {}
609
+
610
+ if not origin.get_remote_host_candidates():
433
611
  self.message_user(
434
612
  request,
435
613
  f"{charger}: remote node connection details are incomplete.",
@@ -465,7 +643,17 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
465
643
  )
466
644
  return False, {}
467
645
 
468
- url = f"http://{origin.address}:{origin.port}/nodes/network/chargers/action/"
646
+ url = next(
647
+ origin.iter_remote_urls("/nodes/network/chargers/action/"),
648
+ "",
649
+ )
650
+ if not url:
651
+ self.message_user(
652
+ request,
653
+ f"{charger}: no reachable hosts were reported for the remote node.",
654
+ level=messages.ERROR,
655
+ )
656
+ return False, {}
469
657
  try:
470
658
  response = requests.post(url, data=payload_json, headers=headers, timeout=5)
471
659
  except RequestException as exc:
ocpp/consumers.py CHANGED
@@ -26,6 +26,7 @@ from .models import (
26
26
  ChargerConfiguration,
27
27
  MeterValue,
28
28
  DataTransferMessage,
29
+ CPReservation,
29
30
  )
30
31
  from .reference_utils import host_is_local_loopback
31
32
  from .evcs_discovery import (
@@ -903,6 +904,43 @@ class CSMSConsumer(AsyncWebsocketConsumer):
903
904
  payload=payload_data,
904
905
  )
905
906
  return
907
+ if action == "ReserveNow":
908
+ status_value = str(payload_data.get("status") or "").strip()
909
+ message = "ReserveNow result"
910
+ if status_value:
911
+ message += f": status={status_value}"
912
+ store.add_log(log_key, message, log_type="charger")
913
+
914
+ reservation_pk = metadata.get("reservation_pk")
915
+
916
+ def _apply():
917
+ if not reservation_pk:
918
+ return
919
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
920
+ if not reservation:
921
+ return
922
+ reservation.evcs_status = status_value
923
+ reservation.evcs_error = ""
924
+ confirmed = status_value.casefold() == "accepted"
925
+ reservation.evcs_confirmed = confirmed
926
+ reservation.evcs_confirmed_at = timezone.now() if confirmed else None
927
+ reservation.save(
928
+ update_fields=[
929
+ "evcs_status",
930
+ "evcs_error",
931
+ "evcs_confirmed",
932
+ "evcs_confirmed_at",
933
+ "updated_on",
934
+ ]
935
+ )
936
+
937
+ await database_sync_to_async(_apply)()
938
+ store.record_pending_call_result(
939
+ message_id,
940
+ metadata=metadata,
941
+ payload=payload_data,
942
+ )
943
+ return
906
944
  if action == "RemoteStartTransaction":
907
945
  status_value = str(payload_data.get("status") or "").strip()
908
946
  message = "RemoteStartTransaction result"
@@ -1084,6 +1122,66 @@ class CSMSConsumer(AsyncWebsocketConsumer):
1084
1122
  error_details=details,
1085
1123
  )
1086
1124
  return
1125
+ if action == "ReserveNow":
1126
+ parts: list[str] = []
1127
+ code_text = (error_code or "").strip() if error_code else ""
1128
+ if code_text:
1129
+ parts.append(f"code={code_text}")
1130
+ description_text = (description or "").strip() if description else ""
1131
+ if description_text:
1132
+ parts.append(f"description={description_text}")
1133
+ details_text = ""
1134
+ if details:
1135
+ try:
1136
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
1137
+ except TypeError:
1138
+ details_text = str(details)
1139
+ if details_text:
1140
+ parts.append(f"details={details_text}")
1141
+ message = "ReserveNow error"
1142
+ if parts:
1143
+ message += ": " + ", ".join(parts)
1144
+ store.add_log(log_key, message, log_type="charger")
1145
+
1146
+ reservation_pk = metadata.get("reservation_pk")
1147
+
1148
+ def _apply():
1149
+ if not reservation_pk:
1150
+ return
1151
+ reservation = CPReservation.objects.filter(pk=reservation_pk).first()
1152
+ if not reservation:
1153
+ return
1154
+ summary_parts = []
1155
+ if code_text:
1156
+ summary_parts.append(code_text)
1157
+ if description_text:
1158
+ summary_parts.append(description_text)
1159
+ if details_text:
1160
+ summary_parts.append(details_text)
1161
+ reservation.evcs_status = ""
1162
+ reservation.evcs_error = "; ".join(summary_parts)
1163
+ reservation.evcs_confirmed = False
1164
+ reservation.evcs_confirmed_at = None
1165
+ reservation.save(
1166
+ update_fields=[
1167
+ "evcs_status",
1168
+ "evcs_error",
1169
+ "evcs_confirmed",
1170
+ "evcs_confirmed_at",
1171
+ "updated_on",
1172
+ ]
1173
+ )
1174
+
1175
+ await database_sync_to_async(_apply)()
1176
+ store.record_pending_call_result(
1177
+ message_id,
1178
+ metadata=metadata,
1179
+ success=False,
1180
+ error_code=error_code,
1181
+ error_description=description,
1182
+ error_details=details,
1183
+ )
1184
+ return
1087
1185
  if action == "RemoteStartTransaction":
1088
1186
  message = "RemoteStartTransaction error"
1089
1187
  if error_code: