arthexis 0.1.16__py3-none-any.whl → 0.1.18__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
@@ -114,6 +114,21 @@ class ReleaseError(Exception):
114
114
  pass
115
115
 
116
116
 
117
+ class PostPublishWarning(ReleaseError):
118
+ """Raised when distribution uploads succeed but post-publish tasks need attention."""
119
+
120
+ def __init__(
121
+ self,
122
+ message: str,
123
+ *,
124
+ uploaded: Sequence[str],
125
+ followups: Optional[Sequence[str]] = None,
126
+ ) -> None:
127
+ super().__init__(message)
128
+ self.uploaded = list(uploaded)
129
+ self.followups = list(followups or [])
130
+
131
+
117
132
  class TestsFailed(ReleaseError):
118
133
  """Raised when the test suite fails.
119
134
 
@@ -711,8 +726,46 @@ def publish(
711
726
  uploaded.append(target.name)
712
727
 
713
728
  tag_name = f"v{version}"
714
- _run(["git", "tag", tag_name])
715
- _push_tag(tag_name, package)
729
+ try:
730
+ _run(["git", "tag", tag_name])
731
+ except subprocess.CalledProcessError as exc:
732
+ details = _format_subprocess_error(exc)
733
+ if uploaded:
734
+ uploads = ", ".join(uploaded)
735
+ if details:
736
+ message = (
737
+ f"Upload to {uploads} completed, but creating git tag {tag_name} failed: {details}"
738
+ )
739
+ else:
740
+ message = (
741
+ f"Upload to {uploads} completed, but creating git tag {tag_name} failed."
742
+ )
743
+ followups = [f"Create and push git tag {tag_name} manually once the repository is ready."]
744
+ raise PostPublishWarning(
745
+ message,
746
+ uploaded=uploaded,
747
+ followups=followups,
748
+ ) from exc
749
+ raise ReleaseError(
750
+ f"Failed to create git tag {tag_name}: {details or exc}"
751
+ ) from exc
752
+
753
+ try:
754
+ _push_tag(tag_name, package)
755
+ except ReleaseError as exc:
756
+ if uploaded:
757
+ uploads = ", ".join(uploaded)
758
+ message = f"Upload to {uploads} completed, but {exc}"
759
+ followups = [
760
+ f"Push git tag {tag_name} to origin after resolving the reported issue."
761
+ ]
762
+ warning = PostPublishWarning(
763
+ message,
764
+ uploaded=uploaded,
765
+ followups=followups,
766
+ )
767
+ raise warning from exc
768
+ raise
716
769
  return uploaded
717
770
 
718
771
 
core/system.py CHANGED
@@ -171,7 +171,7 @@ def _regenerate_changelog() -> None:
171
171
  previous_text = (
172
172
  changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
173
173
  )
174
- range_spec = changelog_utils.determine_range_spec()
174
+ range_spec = changelog_utils.determine_range_spec(previous_text=previous_text)
175
175
  sections = changelog_utils.collect_sections(
176
176
  range_spec=range_spec, previous_text=previous_text
177
177
  )
core/tasks.py CHANGED
@@ -230,12 +230,6 @@ def check_github_updates() -> None:
230
230
 
231
231
  subprocess.run(args, cwd=base_dir, check=True)
232
232
 
233
- if shutil.which("gway"):
234
- try:
235
- subprocess.run(["gway", "upgrade"], check=True)
236
- except subprocess.CalledProcessError:
237
- logger.warning("gway upgrade failed; continuing anyway", exc_info=True)
238
-
239
233
  service_file = base_dir / "locks/service.lck"
240
234
  if service_file.exists():
241
235
  service = service_file.read_text().strip()
core/tests.py CHANGED
@@ -20,6 +20,7 @@ import types
20
20
  from glob import glob
21
21
  from datetime import datetime, timedelta, timezone as datetime_timezone
22
22
  import tempfile
23
+ from io import StringIO
23
24
  from urllib.parse import quote
24
25
 
25
26
  from django.utils import timezone
@@ -57,6 +58,7 @@ from nodes.models import ContentSample
57
58
 
58
59
  from django.core.exceptions import ValidationError
59
60
  from django.core.management import call_command
61
+ from django.core.management.base import CommandError
60
62
  from django.db import IntegrityError
61
63
  from .backends import LocalhostAdminBackend
62
64
  from core.views import (
@@ -548,6 +550,15 @@ class RFIDValidationTests(TestCase):
548
550
  tag = RFID.objects.create(rfid="DEADBEEF10")
549
551
  self.assertEqual(tag.rfid, "DEADBEEF10")
550
552
 
553
+ def test_reversed_uid_updates_with_rfid(self):
554
+ tag = RFID.objects.create(rfid="A1B2C3D4")
555
+ self.assertEqual(tag.reversed_uid, "D4C3B2A1")
556
+
557
+ tag.rfid = "112233"
558
+ tag.save(update_fields=["rfid"])
559
+ tag.refresh_from_db()
560
+ self.assertEqual(tag.reversed_uid, "332211")
561
+
551
562
  def test_find_user_by_rfid(self):
552
563
  user = User.objects.create_user(username="finder", password="pwd")
553
564
  acc = EnergyAccount.objects.create(user=user, name="FINDER")
@@ -983,6 +994,40 @@ class RFIDImportExportCommandTests(TestCase):
983
994
  self.assertTrue(tag.energy_accounts.filter(pk=account.pk).exists())
984
995
 
985
996
 
997
+ class CheckRFIDCommandTests(TestCase):
998
+ def test_successful_validation_outputs_json(self):
999
+ out = StringIO()
1000
+
1001
+ call_command("check_rfid", "abcd1234", stdout=out)
1002
+
1003
+ payload = json.loads(out.getvalue())
1004
+ self.assertEqual(payload["rfid"], "ABCD1234")
1005
+ self.assertTrue(payload["created"])
1006
+ self.assertTrue(RFID.objects.filter(rfid="ABCD1234").exists())
1007
+
1008
+ def test_invalid_value_raises_error(self):
1009
+ with self.assertRaises(CommandError):
1010
+ call_command("check_rfid", "invalid!")
1011
+
1012
+ def test_kind_option_updates_existing_tag(self):
1013
+ tag = RFID.objects.create(rfid="EXISTING", allowed=False, kind=RFID.CLASSIC)
1014
+ out = StringIO()
1015
+
1016
+ call_command(
1017
+ "check_rfid",
1018
+ "existing",
1019
+ "--kind",
1020
+ RFID.NTAG215,
1021
+ stdout=out,
1022
+ )
1023
+
1024
+ payload = json.loads(out.getvalue())
1025
+ tag.refresh_from_db()
1026
+ self.assertFalse(payload["created"])
1027
+ self.assertEqual(payload["kind"], RFID.NTAG215)
1028
+ self.assertEqual(tag.kind, RFID.NTAG215)
1029
+
1030
+
986
1031
  class RFIDKeyVerificationFlagTests(TestCase):
987
1032
  def test_flags_reset_on_key_change(self):
988
1033
  tag = RFID.objects.create(
@@ -1037,6 +1082,44 @@ class ReleaseProcessTests(TestCase):
1037
1082
  )
1038
1083
  sync_main.assert_called_once_with(Path("rel.log"))
1039
1084
 
1085
+ def test_step_check_todos_logs_instruction_when_pending(self):
1086
+ log_path = Path("rel.log")
1087
+ log_path.unlink(missing_ok=True)
1088
+ Todo.objects.create(request="Review checklist")
1089
+ ctx: dict[str, object] = {}
1090
+
1091
+ try:
1092
+ with self.assertRaises(core_views.PendingTodos):
1093
+ core_views._step_check_todos(self.release, ctx, log_path)
1094
+
1095
+ contents = log_path.read_text(encoding="utf-8")
1096
+ message = "Release checklist requires acknowledgment before continuing."
1097
+ self.assertIn(message, contents)
1098
+ self.assertIn("Review outstanding TODO items", contents)
1099
+
1100
+ with self.assertRaises(core_views.PendingTodos):
1101
+ core_views._step_check_todos(self.release, ctx, log_path)
1102
+
1103
+ contents = log_path.read_text(encoding="utf-8")
1104
+ self.assertEqual(contents.count(message), 1)
1105
+ finally:
1106
+ log_path.unlink(missing_ok=True)
1107
+
1108
+ def test_step_check_todos_auto_ack_when_no_pending(self):
1109
+ log_path = Path("rel.log")
1110
+ log_path.unlink(missing_ok=True)
1111
+ ctx: dict[str, object] = {}
1112
+
1113
+ try:
1114
+ with mock.patch("core.views._refresh_changelog_once"):
1115
+ core_views._step_check_todos(self.release, ctx, log_path)
1116
+ finally:
1117
+ log_path.unlink(missing_ok=True)
1118
+
1119
+ self.assertTrue(ctx.get("todos_ack"))
1120
+ self.assertNotIn("todos_required", ctx)
1121
+ self.assertIsNone(ctx.get("todos"))
1122
+
1040
1123
  @mock.patch("core.views._sync_with_origin_main")
1041
1124
  @mock.patch("core.views.release_utils._git_clean", return_value=True)
1042
1125
  @mock.patch("core.views.release_utils.network_available", return_value=False)
@@ -1527,6 +1610,54 @@ class ReleaseProcessTests(TestCase):
1527
1610
  ctx = session.get(f"release_publish_{self.release.pk}")
1528
1611
  self.assertTrue(ctx.get("dry_run"))
1529
1612
 
1613
+ def test_resume_button_shown_when_credentials_missing(self):
1614
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1615
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1616
+ self.client.force_login(user)
1617
+
1618
+ self.client.get(f"{url}?start=1")
1619
+
1620
+ session = self.client.session
1621
+ ctx = session.get(f"release_publish_{self.release.pk}") or {}
1622
+ ctx.update({"step": 7, "started": True, "paused": False})
1623
+ session[f"release_publish_{self.release.pk}"] = ctx
1624
+ session.save()
1625
+
1626
+ response = self.client.get(f"{url}?step=7")
1627
+ self.assertEqual(response.status_code, 200)
1628
+ context = response.context
1629
+ if isinstance(context, list):
1630
+ context = context[-1]
1631
+ self.assertTrue(context["resume_available"])
1632
+ self.assertIn(b"Resume Publish", response.content)
1633
+
1634
+ def test_resume_without_step_parameter_defaults_to_current_progress(self):
1635
+ run: list[str] = []
1636
+
1637
+ def step_fn(release, ctx, log_path):
1638
+ run.append("step")
1639
+
1640
+ steps = [("Only step", step_fn)]
1641
+ user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1642
+ url = reverse("release-progress", args=[self.release.pk, "publish"])
1643
+ with mock.patch("core.views.PUBLISH_STEPS", steps):
1644
+ self.client.force_login(user)
1645
+ session = self.client.session
1646
+ session[f"release_publish_{self.release.pk}"] = {
1647
+ "step": 0,
1648
+ "started": True,
1649
+ "paused": False,
1650
+ }
1651
+ session.save()
1652
+
1653
+ response = self.client.get(f"{url}?resume=1")
1654
+ self.assertEqual(response.status_code, 200)
1655
+ self.assertEqual(run, ["step"])
1656
+
1657
+ session = self.client.session
1658
+ ctx = session.get(f"release_publish_{self.release.pk}")
1659
+ self.assertEqual(ctx.get("step"), 1)
1660
+
1530
1661
  def test_new_todo_does_not_reset_pending_flow(self):
1531
1662
  user = User.objects.create_superuser("admin", "admin@example.com", "pw")
1532
1663
  url = reverse("release-progress", args=[self.release.pk, "publish"])
core/views.py CHANGED
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
43
43
  PYPI_REQUEST_TIMEOUT = 10
44
44
 
45
45
  from . import changelog as changelog_utils
46
+ from . import temp_passwords
46
47
  from .models import OdooProfile, Product, EnergyAccount, PackageRelease, Todo
47
48
  from .models import RFID
48
49
 
@@ -336,6 +337,35 @@ def odoo_quote_report(request):
336
337
  return TemplateResponse(request, "admin/core/odoo_quote_report.html", context)
337
338
 
338
339
 
340
+ @staff_member_required
341
+ @require_GET
342
+ def request_temp_password(request):
343
+ """Generate a temporary password for the authenticated staff member."""
344
+
345
+ user = request.user
346
+ username = user.get_username()
347
+ password = temp_passwords.generate_password()
348
+ entry = temp_passwords.store_temp_password(
349
+ username,
350
+ password,
351
+ allow_change=True,
352
+ )
353
+ context = {
354
+ **admin_site.each_context(request),
355
+ "title": _("Temporary password"),
356
+ "username": username,
357
+ "password": password,
358
+ "expires_at": timezone.localtime(entry.expires_at),
359
+ "allow_change": entry.allow_change,
360
+ "return_url": reverse("admin:password_change"),
361
+ }
362
+ return TemplateResponse(
363
+ request,
364
+ "admin/core/request_temp_password.html",
365
+ context,
366
+ )
367
+
368
+
339
369
  @require_GET
340
370
  def version_info(request):
341
371
  """Return the running application version and Git revision."""
@@ -656,8 +686,8 @@ def _should_use_python_changelog(exc: OSError) -> bool:
656
686
  def _generate_changelog_with_python(log_path: Path) -> None:
657
687
  _append_log(log_path, "Falling back to Python changelog generator")
658
688
  changelog_path = Path("CHANGELOG.rst")
659
- range_spec = changelog_utils.determine_range_spec()
660
689
  previous = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else None
690
+ range_spec = changelog_utils.determine_range_spec(previous_text=previous)
661
691
  sections = changelog_utils.collect_sections(range_spec=range_spec, previous_text=previous)
662
692
  content = changelog_utils.render_changelog(sections)
663
693
  if not content.endswith("\n"):
@@ -897,7 +927,18 @@ def _step_check_todos(release, ctx, log_path: Path) -> None:
897
927
  pending_values = list(
898
928
  pending_qs.values("id", "request", "url", "request_details")
899
929
  )
930
+ if not pending_values:
931
+ ctx["todos_ack"] = True
932
+
900
933
  if not ctx.get("todos_ack"):
934
+ if not ctx.get("todos_block_logged"):
935
+ _append_log(
936
+ log_path,
937
+ "Release checklist requires acknowledgment before continuing. "
938
+ "Review outstanding TODO items and confirm the checklist; "
939
+ "publishing will resume automatically afterward.",
940
+ )
941
+ ctx["todos_block_logged"] = True
901
942
  ctx["todos"] = pending_values
902
943
  ctx["todos_required"] = True
903
944
  raise PendingTodos()
@@ -1451,12 +1492,29 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1451
1492
  )
1452
1493
  else:
1453
1494
  _append_log(log_path, "Uploading distribution")
1454
- release_utils.publish(
1455
- package=release.to_package(),
1456
- version=release.version,
1457
- creds=release.to_credentials(),
1458
- repositories=targets,
1459
- )
1495
+ publish_warning: release_utils.PostPublishWarning | None = None
1496
+ try:
1497
+ release_utils.publish(
1498
+ package=release.to_package(),
1499
+ version=release.version,
1500
+ creds=release.to_credentials(),
1501
+ repositories=targets,
1502
+ )
1503
+ except release_utils.PostPublishWarning as warning:
1504
+ publish_warning = warning
1505
+
1506
+ if publish_warning is not None:
1507
+ message = str(publish_warning)
1508
+ followups = _dedupe_preserve_order(publish_warning.followups)
1509
+ warning_entries = ctx.setdefault("warnings", [])
1510
+ if not any(entry.get("message") == message for entry in warning_entries):
1511
+ entry: dict[str, object] = {"message": message}
1512
+ if followups:
1513
+ entry["followups"] = followups
1514
+ warning_entries.append(entry)
1515
+ _append_log(log_path, message)
1516
+ for note in followups:
1517
+ _append_log(log_path, f"Follow-up: {note}")
1460
1518
  release.pypi_url = (
1461
1519
  f"https://pypi.org/project/{release.package.name}/{release.version}/"
1462
1520
  )
@@ -1804,8 +1862,16 @@ def release_progress(request, pk: int, action: str):
1804
1862
  ctx["release_approval"] = "approved"
1805
1863
  if request.GET.get("reject"):
1806
1864
  ctx["release_approval"] = "rejected"
1865
+ resume_requested = bool(request.GET.get("resume"))
1866
+
1807
1867
  if request.GET.get("pause") and ctx.get("started"):
1808
1868
  ctx["paused"] = True
1869
+
1870
+ if resume_requested:
1871
+ if not ctx.get("started"):
1872
+ ctx["started"] = True
1873
+ if ctx.get("paused"):
1874
+ ctx["paused"] = False
1809
1875
  restart_count = 0
1810
1876
  if restart_path.exists():
1811
1877
  try:
@@ -1814,26 +1880,39 @@ def release_progress(request, pk: int, action: str):
1814
1880
  restart_count = 0
1815
1881
  step_count = ctx.get("step", 0)
1816
1882
  step_param = request.GET.get("step")
1883
+ if resume_requested and step_param is None:
1884
+ step_param = str(step_count)
1817
1885
 
1818
1886
  pending_qs = Todo.objects.filter(is_deleted=False, done_on__isnull=True)
1819
1887
  pending_items = list(pending_qs)
1820
- if ack_todos_requested:
1821
- if pending_items:
1822
- failures = []
1823
- for todo in pending_items:
1824
- result = todo.check_on_done_condition()
1825
- if not result.passed:
1826
- failures.append((todo, result))
1827
- if failures:
1828
- ctx.pop("todos_ack", None)
1829
- for todo, result in failures:
1830
- messages.error(request, _format_condition_failure(todo, result))
1831
- else:
1832
- ctx["todos_ack"] = True
1888
+ if not pending_items:
1889
+ ctx["todos_ack"] = True
1890
+ ctx["todos_ack_auto"] = True
1891
+ elif ack_todos_requested:
1892
+ failures = []
1893
+ for todo in pending_items:
1894
+ result = todo.check_on_done_condition()
1895
+ if not result.passed:
1896
+ failures.append((todo, result))
1897
+ if failures:
1898
+ ctx["todos_ack"] = False
1899
+ ctx.pop("todos_ack_auto", None)
1900
+ for todo, result in failures:
1901
+ messages.error(request, _format_condition_failure(todo, result))
1833
1902
  else:
1834
1903
  ctx["todos_ack"] = True
1904
+ ctx.pop("todos_ack_auto", None)
1905
+ else:
1906
+ if ctx.pop("todos_ack_auto", None):
1907
+ ctx["todos_ack"] = False
1908
+ else:
1909
+ ctx.setdefault("todos_ack", False)
1835
1910
 
1836
- if not ctx.get("todos_ack"):
1911
+ if ctx.get("todos_ack"):
1912
+ ctx.pop("todos_block_logged", None)
1913
+ ctx.pop("todos", None)
1914
+ ctx.pop("todos_required", None)
1915
+ else:
1837
1916
  ctx["todos"] = [
1838
1917
  {
1839
1918
  "id": todo.pk,
@@ -1844,9 +1923,6 @@ def release_progress(request, pk: int, action: str):
1844
1923
  for todo in pending_items
1845
1924
  ]
1846
1925
  ctx["todos_required"] = True
1847
- else:
1848
- ctx.pop("todos", None)
1849
- ctx.pop("todos_required", None)
1850
1926
 
1851
1927
  log_name = _release_log_name(release.package.name, release.version)
1852
1928
  if ctx.get("log") != log_name:
@@ -1856,6 +1932,8 @@ def release_progress(request, pk: int, action: str):
1856
1932
  "started": ctx.get("started", False),
1857
1933
  }
1858
1934
  step_count = 0
1935
+ if not pending_items:
1936
+ ctx["todos_ack"] = True
1859
1937
  log_path = log_dir / log_name
1860
1938
  ctx.setdefault("log", log_name)
1861
1939
  ctx.setdefault("paused", False)
@@ -2049,6 +2127,14 @@ def release_progress(request, pk: int, action: str):
2049
2127
  )
2050
2128
 
2051
2129
  is_running = ctx.get("started") and not paused and not done and not ctx.get("error")
2130
+ resume_available = (
2131
+ ctx.get("started")
2132
+ and not paused
2133
+ and not done
2134
+ and not ctx.get("error")
2135
+ and step_count < len(steps)
2136
+ and next_step is None
2137
+ )
2052
2138
  can_resume = ctx.get("started") and paused and not done and not ctx.get("error")
2053
2139
  release_manager_owner = manager.owner_display() if manager else ""
2054
2140
  try:
@@ -2103,9 +2189,11 @@ def release_progress(request, pk: int, action: str):
2103
2189
  "has_release_manager": bool(manager),
2104
2190
  "current_user_admin_url": current_user_admin_url,
2105
2191
  "is_running": is_running,
2192
+ "resume_available": resume_available,
2106
2193
  "can_resume": can_resume,
2107
2194
  "dry_run": dry_run_active,
2108
2195
  "dry_run_toggle_enabled": dry_run_toggle_enabled,
2196
+ "warnings": ctx.get("warnings", []),
2109
2197
  }
2110
2198
  request.session[session_key] = ctx
2111
2199
  if done or ctx.get("error"):
ocpp/admin.py CHANGED
@@ -29,6 +29,7 @@ from .transactions_io import (
29
29
  import_transactions as import_transactions_data,
30
30
  )
31
31
  from .status_display import STATUS_BADGE_MAP, ERROR_OK_VALUES
32
+ from .views import _charger_state, _live_sessions
32
33
  from core.admin import SaveBeforeChangeAction
33
34
  from core.user_data import EntityModelAdmin
34
35
 
@@ -253,6 +254,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
253
254
  actions = [
254
255
  "purge_data",
255
256
  "fetch_cp_configuration",
257
+ "toggle_rfid_authentication",
258
+ "recheck_charger_status",
256
259
  "change_availability_operative",
257
260
  "change_availability_inoperative",
258
261
  "set_availability_state_operative",
@@ -317,16 +320,14 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
317
320
  "charger-status-connector",
318
321
  args=[obj.charger_id, obj.connector_slug],
319
322
  )
320
- label = (obj.last_status or "status").strip() or "status"
321
- status_key = label.lower()
322
- error_code = (obj.last_error_code or "").strip().lower()
323
- if (
324
- self._has_active_session(obj)
325
- and error_code in ERROR_OK_VALUES
326
- and (status_key not in STATUS_BADGE_MAP or status_key == "available")
327
- ):
328
- label = STATUS_BADGE_MAP["charging"][0]
329
- return format_html('<a href="{}" target="_blank">{}</a>', url, label)
323
+ tx_obj = store.get_transaction(obj.charger_id, obj.connector_id)
324
+ state, _ = _charger_state(
325
+ obj,
326
+ tx_obj
327
+ if obj.connector_id is not None
328
+ else (_live_sessions(obj) or None),
329
+ )
330
+ return format_html('<a href="{}" target="_blank">{}</a>', url, state)
330
331
 
331
332
  status_link.short_description = "Status"
332
333
 
@@ -359,6 +360,63 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
359
360
 
360
361
  purge_data.short_description = "Purge data"
361
362
 
363
+ @admin.action(description="Re-check Charger Status")
364
+ def recheck_charger_status(self, request, queryset):
365
+ requested = 0
366
+ for charger in queryset:
367
+ connector_value = charger.connector_id
368
+ ws = store.get_connection(charger.charger_id, connector_value)
369
+ if ws is None:
370
+ self.message_user(
371
+ request,
372
+ f"{charger}: no active connection",
373
+ level=messages.ERROR,
374
+ )
375
+ continue
376
+ payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
377
+ trigger_connector: int | None = None
378
+ if connector_value is not None:
379
+ payload["connectorId"] = connector_value
380
+ trigger_connector = connector_value
381
+ message_id = uuid.uuid4().hex
382
+ msg = json.dumps([2, message_id, "TriggerMessage", payload])
383
+ try:
384
+ async_to_sync(ws.send)(msg)
385
+ except Exception as exc: # pragma: no cover - network error
386
+ self.message_user(
387
+ request,
388
+ f"{charger}: failed to send TriggerMessage ({exc})",
389
+ level=messages.ERROR,
390
+ )
391
+ continue
392
+ log_key = store.identity_key(charger.charger_id, connector_value)
393
+ store.add_log(log_key, f"< {msg}", log_type="charger")
394
+ store.register_pending_call(
395
+ message_id,
396
+ {
397
+ "action": "TriggerMessage",
398
+ "charger_id": charger.charger_id,
399
+ "connector_id": connector_value,
400
+ "log_key": log_key,
401
+ "trigger_target": "StatusNotification",
402
+ "trigger_connector": trigger_connector,
403
+ "requested_at": timezone.now(),
404
+ },
405
+ )
406
+ store.schedule_call_timeout(
407
+ message_id,
408
+ timeout=5.0,
409
+ action="TriggerMessage",
410
+ log_key=log_key,
411
+ message="TriggerMessage StatusNotification timed out",
412
+ )
413
+ requested += 1
414
+ if requested:
415
+ self.message_user(
416
+ request,
417
+ f"Requested status update from {requested} charger(s)",
418
+ )
419
+
362
420
  @admin.action(description="Fetch CP configuration")
363
421
  def fetch_cp_configuration(self, request, queryset):
364
422
  fetched = 0
@@ -413,6 +471,30 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
413
471
  f"Requested configuration from {fetched} charger(s)",
414
472
  )
415
473
 
474
+ @admin.action(description="Toggle RFID Authentication")
475
+ def toggle_rfid_authentication(self, request, queryset):
476
+ enabled = 0
477
+ disabled = 0
478
+ for charger in queryset:
479
+ new_value = not charger.require_rfid
480
+ Charger.objects.filter(pk=charger.pk).update(require_rfid=new_value)
481
+ charger.require_rfid = new_value
482
+ if new_value:
483
+ enabled += 1
484
+ else:
485
+ disabled += 1
486
+ if enabled or disabled:
487
+ changes = []
488
+ if enabled:
489
+ changes.append(f"enabled for {enabled} charger(s)")
490
+ if disabled:
491
+ changes.append(f"disabled for {disabled} charger(s)")
492
+ summary = "; ".join(changes)
493
+ self.message_user(
494
+ request,
495
+ f"Updated RFID authentication: {summary}",
496
+ )
497
+
416
498
  def _dispatch_change_availability(self, request, queryset, availability_type: str):
417
499
  sent = 0
418
500
  for charger in queryset: