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.

core/release.py CHANGED
@@ -520,11 +520,6 @@ def build(
520
520
  if not version_path.exists():
521
521
  raise ReleaseError("VERSION file not found")
522
522
  version = version_path.read_text().strip()
523
- if bump:
524
- major, minor, patch = map(int, version.split("."))
525
- patch += 1
526
- version = f"{major}.{minor}.{patch}"
527
- version_path.write_text(version + "\n")
528
523
  else:
529
524
  # Ensure the VERSION file reflects the provided release version
530
525
  if version_path.parent != Path("."):
core/tasks.py CHANGED
@@ -405,3 +405,28 @@ def run_client_report_schedule(schedule_id: int) -> None:
405
405
  except Exception:
406
406
  logger.exception("ClientReportSchedule %s failed", schedule_id)
407
407
  raise
408
+
409
+
410
+ @shared_task
411
+ def ensure_recurring_client_reports() -> None:
412
+ """Ensure scheduled consumer reports run for the current period."""
413
+
414
+ from core.models import ClientReportSchedule
415
+
416
+ reference = timezone.localdate()
417
+ schedules = ClientReportSchedule.objects.filter(
418
+ periodicity__in=[
419
+ ClientReportSchedule.PERIODICITY_DAILY,
420
+ ClientReportSchedule.PERIODICITY_WEEKLY,
421
+ ClientReportSchedule.PERIODICITY_MONTHLY,
422
+ ]
423
+ ).prefetch_related("chargers")
424
+
425
+ for schedule in schedules:
426
+ try:
427
+ schedule.generate_missing_reports(reference=reference)
428
+ except Exception:
429
+ logger.exception(
430
+ "Automatic consumer report generation failed for schedule %s",
431
+ schedule.pk,
432
+ )
core/tests.py CHANGED
@@ -2138,6 +2138,25 @@ class PackageReleaseCurrentTests(TestCase):
2138
2138
  self.package.save()
2139
2139
  self.assertFalse(self.release.is_current)
2140
2140
 
2141
+ def test_is_current_false_when_version_has_plus(self):
2142
+ self.version_path.write_text("1.0.0+")
2143
+ self.assertFalse(self.release.is_current)
2144
+
2145
+
2146
+ class PackageReleaseRevisionTests(TestCase):
2147
+ def setUp(self):
2148
+ self.package = Package.objects.get(name="arthexis")
2149
+ self.release = PackageRelease.objects.create(
2150
+ package=self.package,
2151
+ version="1.0.0",
2152
+ revision="abcdef123456",
2153
+ )
2154
+
2155
+ def test_matches_revision_ignores_plus_suffix(self):
2156
+ self.assertTrue(
2157
+ PackageRelease.matches_revision("1.0.0+", "abcdef123456")
2158
+ )
2159
+
2141
2160
  def test_is_current_false_when_version_differs(self):
2142
2161
  self.release.version = "2.0.0"
2143
2162
  self.release.save()
@@ -2188,13 +2207,22 @@ class PackageAdminPrepareNextReleaseTests(TestCase):
2188
2207
 
2189
2208
  def test_prepare_next_release_active_creates_release(self):
2190
2209
  PackageRelease.all_objects.filter(package=self.package).delete()
2191
- request = self.factory.get("/admin/core/package/prepare-next-release/")
2210
+ request = self.factory.post("/admin/core/package/prepare-next-release/")
2192
2211
  response = self.admin.prepare_next_release_active(request)
2193
2212
  self.assertEqual(response.status_code, 302)
2194
2213
  self.assertEqual(
2195
2214
  PackageRelease.all_objects.filter(package=self.package).count(), 1
2196
2215
  )
2197
2216
 
2217
+ def test_prepare_next_release_active_get_creates_release(self):
2218
+ PackageRelease.all_objects.filter(package=self.package).delete()
2219
+ request = self.factory.get("/admin/core/package/prepare-next-release/")
2220
+ response = self.admin.prepare_next_release_active(request)
2221
+ self.assertEqual(response.status_code, 302)
2222
+ self.assertTrue(
2223
+ PackageRelease.all_objects.filter(package=self.package).exists()
2224
+ )
2225
+
2198
2226
 
2199
2227
  class PackageAdminChangeViewTests(TestCase):
2200
2228
  def setUp(self):
core/user_data.py CHANGED
@@ -201,9 +201,49 @@ def dump_user_fixture(instance, user=None) -> None:
201
201
 
202
202
  def delete_user_fixture(instance, user=None) -> None:
203
203
  target_user = user or _resolve_fixture_user(instance)
204
- if target_user is None:
204
+ filename = (
205
+ f"{instance._meta.app_label}_{instance._meta.model_name}_{instance.pk}.json"
206
+ )
207
+
208
+ def _remove_for_user(candidate) -> None:
209
+ if candidate is None:
210
+ return
211
+ base_path = Path(
212
+ getattr(candidate, "data_path", "") or Path(settings.BASE_DIR) / "data"
213
+ )
214
+ username = _username_for(candidate)
215
+ if not username:
216
+ return
217
+ user_dir = base_path / username
218
+ if user_dir.exists():
219
+ (user_dir / filename).unlink(missing_ok=True)
220
+
221
+ if target_user is not None:
222
+ _remove_for_user(target_user)
205
223
  return
206
- _fixture_path(target_user, instance).unlink(missing_ok=True)
224
+
225
+ root = Path(settings.BASE_DIR) / "data"
226
+ if root.exists():
227
+ (root / filename).unlink(missing_ok=True)
228
+ for path in root.iterdir():
229
+ if path.is_dir():
230
+ (path / filename).unlink(missing_ok=True)
231
+
232
+ UserModel = get_user_model()
233
+ manager = getattr(UserModel, "all_objects", UserModel._default_manager)
234
+ for candidate in manager.all():
235
+ data_path = getattr(candidate, "data_path", "")
236
+ if not data_path:
237
+ continue
238
+ base_path = Path(data_path)
239
+ if not base_path.exists():
240
+ continue
241
+ username = _username_for(candidate)
242
+ if not username:
243
+ continue
244
+ user_dir = base_path / username
245
+ if user_dir.exists():
246
+ (user_dir / filename).unlink(missing_ok=True)
207
247
 
208
248
 
209
249
  def _mark_fixture_user_data(path: Path) -> None:
core/views.py CHANGED
@@ -690,16 +690,17 @@ def _ensure_origin_main_unchanged(log_path: Path) -> None:
690
690
  def _next_patch_version(version: str) -> str:
691
691
  from packaging.version import InvalidVersion, Version
692
692
 
693
+ cleaned = version.rstrip("+")
693
694
  try:
694
- parsed = Version(version)
695
+ parsed = Version(cleaned)
695
696
  except InvalidVersion:
696
- parts = version.split(".")
697
+ parts = cleaned.split(".") if cleaned else []
697
698
  for index in range(len(parts) - 1, -1, -1):
698
699
  segment = parts[index]
699
700
  if segment.isdigit():
700
701
  parts[index] = str(int(segment) + 1)
701
702
  return ".".join(parts)
702
- return version
703
+ return cleaned or version
703
704
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
704
705
 
705
706
 
@@ -768,7 +769,9 @@ def _sync_release_with_revision(release: PackageRelease) -> tuple[bool, str]:
768
769
  version_path = Path("VERSION")
769
770
  if version_path.exists():
770
771
  try:
771
- repo_version = Version(version_path.read_text(encoding="utf-8").strip())
772
+ raw_version = version_path.read_text(encoding="utf-8").strip()
773
+ cleaned_version = raw_version.rstrip("+") or "0.0.0"
774
+ repo_version = Version(cleaned_version)
772
775
  except InvalidVersion:
773
776
  repo_version = None
774
777
 
@@ -935,7 +938,7 @@ def _refresh_changelog_once(ctx, log_path: Path) -> None:
935
938
  ctx["changelog_refreshed"] = True
936
939
 
937
940
 
938
- def _step_check_todos(release, ctx, log_path: Path) -> None:
941
+ def _step_check_todos(release, ctx, log_path: Path, *, user=None) -> None:
939
942
  _refresh_changelog_once(ctx, log_path)
940
943
 
941
944
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
@@ -975,7 +978,7 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
975
978
  ctx["todos_ack"] = True
976
979
 
977
980
 
978
- def _step_check_version(release, ctx, log_path: Path) -> None:
981
+ def _step_check_version(release, ctx, log_path: Path, *, user=None) -> None:
979
982
  from . import release as release_utils
980
983
  from packaging.version import InvalidVersion, Version
981
984
 
@@ -1110,10 +1113,12 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1110
1113
  version_path = Path("VERSION")
1111
1114
  if version_path.exists():
1112
1115
  current = version_path.read_text(encoding="utf-8").strip()
1113
- if current and Version(release.version) < Version(current):
1114
- raise Exception(
1115
- f"Version {release.version} is older than existing {current}"
1116
- )
1116
+ if current:
1117
+ current_clean = current.rstrip("+") or "0.0.0"
1118
+ if Version(release.version) < Version(current_clean):
1119
+ raise Exception(
1120
+ f"Version {release.version} is older than existing {current}"
1121
+ )
1117
1122
 
1118
1123
  _append_log(log_path, f"Checking if version {release.version} exists on PyPI")
1119
1124
  if release_utils.network_available():
@@ -1163,17 +1168,17 @@ def _step_check_version(release, ctx, log_path: Path) -> None:
1163
1168
  _append_log(log_path, "Network unavailable, skipping PyPI check")
1164
1169
 
1165
1170
 
1166
- def _step_handle_migrations(release, ctx, log_path: Path) -> None:
1171
+ def _step_handle_migrations(release, ctx, log_path: Path, *, user=None) -> None:
1167
1172
  _append_log(log_path, "Freeze, squash and approve migrations")
1168
1173
  _append_log(log_path, "Migration review acknowledged (manual step)")
1169
1174
 
1170
1175
 
1171
- def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1176
+ def _step_changelog_docs(release, ctx, log_path: Path, *, user=None) -> None:
1172
1177
  _append_log(log_path, "Compose CHANGELOG and documentation")
1173
1178
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1174
1179
 
1175
1180
 
1176
- def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1181
+ def _step_pre_release_actions(release, ctx, log_path: Path, *, user=None) -> None:
1177
1182
  _append_log(log_path, "Execute pre-release actions")
1178
1183
  if ctx.get("dry_run"):
1179
1184
  _append_log(log_path, "Dry run: skipping pre-release actions")
@@ -1249,12 +1254,12 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1249
1254
  _append_log(log_path, "Pre-release actions complete")
1250
1255
 
1251
1256
 
1252
- def _step_run_tests(release, ctx, log_path: Path) -> None:
1257
+ def _step_run_tests(release, ctx, log_path: Path, *, user=None) -> None:
1253
1258
  _append_log(log_path, "Complete test suite with --all flag")
1254
1259
  _append_log(log_path, "Test suite completion acknowledged")
1255
1260
 
1256
1261
 
1257
- def _step_promote_build(release, ctx, log_path: Path) -> None:
1262
+ def _step_promote_build(release, ctx, log_path: Path, *, user=None) -> None:
1258
1263
  from . import release as release_utils
1259
1264
 
1260
1265
  _append_log(log_path, "Generating build files")
@@ -1266,7 +1271,7 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1266
1271
  release_utils.promote(
1267
1272
  package=release.to_package(),
1268
1273
  version=release.version,
1269
- creds=release.to_credentials(),
1274
+ creds=release.to_credentials(user=user),
1270
1275
  )
1271
1276
  _append_log(
1272
1277
  log_path,
@@ -1314,8 +1319,10 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1314
1319
  _append_log(new_log, "Build complete")
1315
1320
 
1316
1321
 
1317
- def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1318
- if release.to_credentials() is None:
1322
+ def _step_release_manager_approval(
1323
+ release, ctx, log_path: Path, *, user=None
1324
+ ) -> None:
1325
+ if release.to_credentials(user=user) is None:
1319
1326
  ctx.pop("release_approval", None)
1320
1327
  if not ctx.get("approval_credentials_missing"):
1321
1328
  _append_log(log_path, "Release manager publishing credentials missing")
@@ -1349,14 +1356,14 @@ def _step_release_manager_approval(release, ctx, log_path: Path) -> None:
1349
1356
  raise ApprovalRequired()
1350
1357
 
1351
1358
 
1352
- def _step_publish(release, ctx, log_path: Path) -> None:
1359
+ def _step_publish(release, ctx, log_path: Path, *, user=None) -> None:
1353
1360
  from . import release as release_utils
1354
1361
 
1355
1362
  if ctx.get("dry_run"):
1356
1363
  test_repository_url = os.environ.get(
1357
1364
  "PYPI_TEST_REPOSITORY_URL", "https://test.pypi.org/legacy/"
1358
1365
  )
1359
- test_creds = release.to_credentials()
1366
+ test_creds = release.to_credentials(user=user)
1360
1367
  if not (test_creds and test_creds.has_auth()):
1361
1368
  test_creds = release_utils.Credentials(
1362
1369
  token=os.environ.get("PYPI_TEST_API_TOKEN"),
@@ -1396,7 +1403,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1396
1403
  release_utils.build(
1397
1404
  package=package,
1398
1405
  version=release.version,
1399
- creds=release.to_credentials(),
1406
+ creds=release.to_credentials(user=user),
1400
1407
  dist=True,
1401
1408
  tests=False,
1402
1409
  twine=False,
@@ -1425,13 +1432,13 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1425
1432
  release_utils.publish(
1426
1433
  package=release.to_package(),
1427
1434
  version=release.version,
1428
- creds=target.credentials or release.to_credentials(),
1435
+ creds=target.credentials or release.to_credentials(user=user),
1429
1436
  repositories=[target],
1430
1437
  )
1431
1438
  _append_log(log_path, "Dry run: skipped release metadata updates")
1432
1439
  return
1433
1440
 
1434
- targets = release.build_publish_targets()
1441
+ targets = release.build_publish_targets(user=user)
1435
1442
  repo_labels = []
1436
1443
  for target in targets:
1437
1444
  label = target.name
@@ -1450,7 +1457,7 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1450
1457
  release_utils.publish(
1451
1458
  package=release.to_package(),
1452
1459
  version=release.version,
1453
- creds=release.to_credentials(),
1460
+ creds=release.to_credentials(user=user),
1454
1461
  repositories=targets,
1455
1462
  )
1456
1463
  except release_utils.PostPublishWarning as warning:
@@ -1819,7 +1826,7 @@ def release_progress(request, pk: int, action: str):
1819
1826
  return redirect(request.path)
1820
1827
 
1821
1828
  manager = release.release_manager or release.package.release_manager
1822
- credentials_ready = bool(release.to_credentials())
1829
+ credentials_ready = bool(release.to_credentials(user=request.user))
1823
1830
  if credentials_ready and ctx.get("approval_credentials_missing"):
1824
1831
  ctx.pop("approval_credentials_missing", None)
1825
1832
 
@@ -1990,7 +1997,7 @@ def release_progress(request, pk: int, action: str):
1990
1997
  if to_run == step_count:
1991
1998
  name, func = steps[to_run]
1992
1999
  try:
1993
- func(release, ctx, log_path)
2000
+ func(release, ctx, log_path, user=request.user)
1994
2001
  except PendingTodos:
1995
2002
  pass
1996
2003
  except ApprovalRequired:
nodes/admin.py CHANGED
@@ -19,7 +19,7 @@ from django.utils.html import format_html, format_html_join
19
19
  from django.utils.translation import gettext_lazy as _
20
20
  from pathlib import Path
21
21
  from types import SimpleNamespace
22
- from urllib.parse import urlparse, urlsplit, urlunsplit
22
+ from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit
23
23
  import base64
24
24
  import ipaddress
25
25
  import json
@@ -60,6 +60,7 @@ from .models import (
60
60
  )
61
61
  from . import dns as dns_utils
62
62
  from core.models import RFID
63
+ from ocpp.models import Charger, Location
63
64
  from core.user_data import EntityModelAdmin
64
65
 
65
66
 
@@ -237,7 +238,6 @@ class NodeAdmin(EntityModelAdmin):
237
238
  "relation",
238
239
  "last_seen",
239
240
  "visit_link",
240
- "proxy_link",
241
241
  )
242
242
  search_fields = ("hostname", "address", "mac_address")
243
243
  change_list_template = "admin/nodes/node/change_list.html"
@@ -287,6 +287,7 @@ class NodeAdmin(EntityModelAdmin):
287
287
  "register_visitor",
288
288
  "run_task",
289
289
  "take_screenshots",
290
+ "discover_charge_points",
290
291
  "import_rfids_from_selected",
291
292
  "export_rfids_to_selected",
292
293
  ]
@@ -296,16 +297,6 @@ class NodeAdmin(EntityModelAdmin):
296
297
  def relation(self, obj):
297
298
  return obj.get_current_relation_display()
298
299
 
299
- @admin.display(description=_("Proxy"))
300
- def proxy_link(self, obj):
301
- if not obj or obj.is_local:
302
- return ""
303
- try:
304
- url = reverse("admin:nodes_node_proxy", args=[obj.pk])
305
- except NoReverseMatch:
306
- return ""
307
- return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
308
-
309
300
  @admin.display(description=_("Visit"))
310
301
  def visit_link(self, obj):
311
302
  if not obj:
@@ -372,11 +363,6 @@ class NodeAdmin(EntityModelAdmin):
372
363
  self.admin_site.admin_view(self.update_selected_progress),
373
364
  name="nodes_node_update_selected_progress",
374
365
  ),
375
- path(
376
- "<int:node_id>/proxy/",
377
- self.admin_site.admin_view(self.proxy_node),
378
- name="nodes_node_proxy",
379
- ),
380
366
  ]
381
367
  return custom + urls
382
368
 
@@ -409,121 +395,6 @@ class NodeAdmin(EntityModelAdmin):
409
395
  )
410
396
  return response
411
397
 
412
- def _load_local_private_key(self, node):
413
- security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
414
- priv_path = security_dir / f"{node.public_endpoint}"
415
- if not priv_path.exists():
416
- return None, _("Local node private key not found.")
417
- try:
418
- return (
419
- serialization.load_pem_private_key(
420
- priv_path.read_bytes(), password=None
421
- ),
422
- "",
423
- )
424
- except Exception as exc: # pragma: no cover - unexpected errors
425
- return None, str(exc)
426
-
427
- def _build_proxy_payload(self, request, local_node):
428
- user = request.user
429
- payload = {
430
- "requester": str(local_node.uuid),
431
- "user": {
432
- "username": user.get_username(),
433
- "email": user.email or "",
434
- "first_name": user.first_name or "",
435
- "last_name": user.last_name or "",
436
- "is_staff": user.is_staff,
437
- "is_superuser": user.is_superuser,
438
- "groups": list(user.groups.values_list("name", flat=True)),
439
- "permissions": sorted(user.get_all_permissions()),
440
- },
441
- "target": reverse("admin:index"),
442
- }
443
- return payload
444
-
445
- def _start_proxy_session(self, request, node):
446
- if node.is_local:
447
- return {"ok": False, "message": _("Local node cannot be proxied.")}
448
-
449
- local_node = Node.get_local()
450
- if local_node is None:
451
- try:
452
- local_node, _ = Node.register_current()
453
- except Exception as exc: # pragma: no cover - unexpected errors
454
- return {"ok": False, "message": str(exc)}
455
-
456
- private_key, error = self._load_local_private_key(local_node)
457
- if private_key is None:
458
- return {"ok": False, "message": error}
459
-
460
- payload = self._build_proxy_payload(request, local_node)
461
- body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
462
- try:
463
- signature = private_key.sign(
464
- body.encode(),
465
- padding.PKCS1v15(),
466
- hashes.SHA256(),
467
- )
468
- except Exception as exc: # pragma: no cover - unexpected errors
469
- return {"ok": False, "message": str(exc)}
470
-
471
- headers = {
472
- "Content-Type": "application/json",
473
- "X-Signature": base64.b64encode(signature).decode(),
474
- }
475
-
476
- last_error = ""
477
- for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
478
- try:
479
- response = requests.post(url, data=body, headers=headers, timeout=5)
480
- except RequestException as exc:
481
- last_error = str(exc)
482
- continue
483
- if not response.ok:
484
- last_error = f"{response.status_code} {response.text}"
485
- continue
486
- try:
487
- data = response.json()
488
- except ValueError:
489
- last_error = "Invalid JSON response"
490
- continue
491
- login_url = data.get("login_url")
492
- if not login_url:
493
- last_error = "login_url missing"
494
- continue
495
- return {
496
- "ok": True,
497
- "login_url": login_url,
498
- "expires": data.get("expires"),
499
- }
500
-
501
- return {
502
- "ok": False,
503
- "message": last_error or "Unable to initiate proxy.",
504
- }
505
-
506
- def proxy_node(self, request, node_id):
507
- node = self.get_queryset(request).filter(pk=node_id).first()
508
- if not node:
509
- raise Http404
510
- if not self.has_view_permission(request):
511
- raise PermissionDenied
512
- result = self._start_proxy_session(request, node)
513
- if not result.get("ok"):
514
- message = result.get("message") or _("Unable to proxy node.")
515
- self.message_user(request, message, messages.ERROR)
516
- return redirect("admin:nodes_node_changelist")
517
-
518
- context = {
519
- **self.admin_site.each_context(request),
520
- "opts": self.model._meta,
521
- "node": node,
522
- "frame_url": result.get("login_url"),
523
- "expires": result.get("expires"),
524
- }
525
- return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
526
-
527
398
  @admin.action(description="Register Visitor")
528
399
  def register_visitor(self, request, queryset=None):
529
400
  return self.register_visitor_view(request)
@@ -1189,6 +1060,156 @@ class NodeAdmin(EntityModelAdmin):
1189
1060
 
1190
1061
  return self._render_rfid_sync(request, "export", results)
1191
1062
 
1063
+ @admin.action(description=_("Discover Charge Points"))
1064
+ def discover_charge_points(self, request, queryset):
1065
+ local_node, private_key, error = self._load_local_node_credentials()
1066
+ if error:
1067
+ self.message_user(request, error, level=messages.ERROR)
1068
+ return
1069
+
1070
+ nodes = [node for node in queryset if not local_node.pk or node.pk != local_node.pk]
1071
+ if not nodes:
1072
+ self.message_user(request, _("No remote nodes selected."), level=messages.WARNING)
1073
+ return
1074
+
1075
+ payload = json.dumps(
1076
+ {"requester": str(local_node.uuid)},
1077
+ separators=(",", ":"),
1078
+ sort_keys=True,
1079
+ )
1080
+ signature = self._sign_payload(private_key, payload)
1081
+ headers = {
1082
+ "Content-Type": "application/json",
1083
+ "X-Signature": signature,
1084
+ }
1085
+
1086
+ created = 0
1087
+ updated = 0
1088
+ errors: list[str] = []
1089
+
1090
+ for node in nodes:
1091
+ url = f"http://{node.address}:{node.port}/nodes/network/chargers/"
1092
+ try:
1093
+ response = requests.post(url, data=payload, headers=headers, timeout=5)
1094
+ except RequestException as exc:
1095
+ errors.append(f"{node}: {exc}")
1096
+ continue
1097
+
1098
+ if response.status_code != 200:
1099
+ errors.append(f"{node}: {response.status_code} {response.text}")
1100
+ continue
1101
+
1102
+ try:
1103
+ data = response.json()
1104
+ except ValueError:
1105
+ errors.append(f"{node}: invalid JSON response")
1106
+ continue
1107
+
1108
+ for entry in data.get("chargers", []):
1109
+ applied = self._apply_remote_charger_payload(node, entry)
1110
+ if applied == "created":
1111
+ created += 1
1112
+ elif applied == "updated":
1113
+ updated += 1
1114
+
1115
+ if created or updated:
1116
+ summary = _("Imported %(created)s new and %(updated)s existing charge point(s).") % {
1117
+ "created": created,
1118
+ "updated": updated,
1119
+ }
1120
+ self.message_user(request, summary, level=messages.SUCCESS)
1121
+ if errors:
1122
+ for error in errors:
1123
+ self.message_user(request, error, level=messages.ERROR)
1124
+
1125
+ def _apply_remote_charger_payload(self, node, payload: Mapping) -> str | None:
1126
+ serial = Charger.normalize_serial(payload.get("charger_id"))
1127
+ if not serial or Charger.is_placeholder_serial(serial):
1128
+ return None
1129
+
1130
+ connector_value = payload.get("connector_id")
1131
+ if connector_value in ("", None):
1132
+ connector_value = None
1133
+ elif isinstance(connector_value, str):
1134
+ try:
1135
+ connector_value = int(connector_value)
1136
+ except ValueError:
1137
+ connector_value = None
1138
+
1139
+ charger, created = Charger.objects.get_or_create(
1140
+ charger_id=serial,
1141
+ connector_id=connector_value,
1142
+ )
1143
+
1144
+ location_obj = None
1145
+ location_payload = payload.get("location")
1146
+ if isinstance(location_payload, Mapping):
1147
+ name = location_payload.get("name")
1148
+ if name:
1149
+ location_obj, _ = Location.objects.get_or_create(name=name)
1150
+ simple_fields = [
1151
+ "latitude",
1152
+ "longitude",
1153
+ "zone",
1154
+ "contract_type",
1155
+ ]
1156
+ for field in simple_fields:
1157
+ value = location_payload.get(field)
1158
+ setattr(location_obj, field, value)
1159
+ location_obj.save()
1160
+
1161
+ datetime_fields = [
1162
+ "firmware_timestamp",
1163
+ "last_heartbeat",
1164
+ "availability_state_updated_at",
1165
+ "availability_requested_at",
1166
+ "availability_request_status_at",
1167
+ "diagnostics_timestamp",
1168
+ "last_status_timestamp",
1169
+ ]
1170
+
1171
+ updates: dict[str, object] = {
1172
+ "node_origin": node,
1173
+ "allow_remote": bool(payload.get("allow_remote", False)),
1174
+ "export_transactions": bool(payload.get("export_transactions", False)),
1175
+ "last_online_at": timezone.now(),
1176
+ }
1177
+
1178
+ simple_fields = [
1179
+ "display_name",
1180
+ "language",
1181
+ "public_display",
1182
+ "require_rfid",
1183
+ "firmware_status",
1184
+ "firmware_status_info",
1185
+ "last_status",
1186
+ "last_error_code",
1187
+ "last_status_vendor_info",
1188
+ "availability_state",
1189
+ "availability_requested_state",
1190
+ "availability_request_status",
1191
+ "availability_request_details",
1192
+ "temperature",
1193
+ "temperature_unit",
1194
+ "diagnostics_status",
1195
+ "diagnostics_location",
1196
+ ]
1197
+ for field in simple_fields:
1198
+ updates[field] = payload.get(field)
1199
+
1200
+ if location_obj is not None:
1201
+ updates["location"] = location_obj
1202
+
1203
+ for field in datetime_fields:
1204
+ value = payload.get(field)
1205
+ updates[field] = parse_datetime(value) if value else None
1206
+
1207
+ for field in ("last_meter_values",):
1208
+ updates[field] = payload.get(field) or {}
1209
+
1210
+ Charger.objects.filter(pk=charger.pk).update(**updates)
1211
+ return "created" if created else "updated"
1212
+
1192
1213
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
1193
1214
  extra_context = extra_context or {}
1194
1215
  if object_id: