arthexis 0.1.19__py3-none-any.whl → 0.1.21__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.
core/tasks.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  import shutil
5
+ import re
5
6
  import subprocess
6
7
  from pathlib import Path
7
8
  import urllib.error
@@ -102,6 +103,21 @@ def _resolve_service_url(base_dir: Path) -> str:
102
103
  return f"http://127.0.0.1:{port}/"
103
104
 
104
105
 
106
+ def _parse_major_minor(version: str) -> tuple[int, int] | None:
107
+ match = re.match(r"^\s*(\d+)\.(\d+)", version)
108
+ if not match:
109
+ return None
110
+ return int(match.group(1)), int(match.group(2))
111
+
112
+
113
+ def _shares_stable_series(local: str, remote: str) -> bool:
114
+ local_parts = _parse_major_minor(local)
115
+ remote_parts = _parse_major_minor(remote)
116
+ if not local_parts or not remote_parts:
117
+ return False
118
+ return local_parts == remote_parts
119
+
120
+
105
121
  @shared_task
106
122
  def check_github_updates() -> None:
107
123
  """Check the GitHub repo for updates and upgrade if needed."""
@@ -196,9 +212,16 @@ def check_github_updates() -> None:
196
212
  if startup:
197
213
  startup()
198
214
  return
215
+ if mode == "stable" and _shares_stable_series(local, remote):
216
+ if startup:
217
+ startup()
218
+ return
199
219
  if notify:
200
220
  notify("Upgrading...", upgrade_stamp)
201
- args = ["./upgrade.sh", "--no-restart"]
221
+ if mode == "stable":
222
+ args = ["./upgrade.sh", "--stable", "--no-restart"]
223
+ else:
224
+ args = ["./upgrade.sh", "--no-restart"]
202
225
  upgrade_was_applied = True
203
226
 
204
227
  with log_file.open("a") as fh:
core/tests.py CHANGED
@@ -8,6 +8,7 @@ django.setup()
8
8
  from django.test import Client, TestCase, RequestFactory, override_settings
9
9
  from django.urls import reverse
10
10
  from django.http import HttpRequest
11
+ from django.contrib import messages
11
12
  import csv
12
13
  import json
13
14
  import importlib.util
@@ -1289,11 +1290,10 @@ class ReleaseProcessTests(TestCase):
1289
1290
  run.assert_any_call(["git", "clean", "-fd"], check=False)
1290
1291
 
1291
1292
  @mock.patch("core.views.PackageRelease.dump_fixture")
1292
- @mock.patch("core.views._ensure_release_todo")
1293
1293
  @mock.patch("core.views._sync_with_origin_main")
1294
1294
  @mock.patch("core.views.subprocess.run")
1295
1295
  def test_pre_release_syncs_with_main(
1296
- self, run, sync_main, ensure_todo, dump_fixture
1296
+ self, run, sync_main, dump_fixture
1297
1297
  ):
1298
1298
  import subprocess as sp
1299
1299
 
@@ -1305,11 +1305,6 @@ class ReleaseProcessTests(TestCase):
1305
1305
  return sp.CompletedProcess(cmd, 0)
1306
1306
 
1307
1307
  run.side_effect = fake_run
1308
- ensure_todo.return_value = (
1309
- mock.Mock(request="Create release pkg 1.0.1", url="", request_details=""),
1310
- Path("core/fixtures/todos__next_release.json"),
1311
- )
1312
-
1313
1308
  version_path = Path("VERSION")
1314
1309
  original_version = version_path.read_text(encoding="utf-8")
1315
1310
 
core/views.py CHANGED
@@ -19,7 +19,6 @@ from django.shortcuts import get_object_or_404, redirect, render, resolve_url
19
19
  from django.template.response import TemplateResponse
20
20
  from django.utils import timezone
21
21
  from django.utils.html import strip_tags
22
- from django.utils.text import slugify
23
22
  from django.utils.translation import gettext as _
24
23
  from django.urls import NoReverseMatch, reverse
25
24
  from django.views.decorators.csrf import csrf_exempt
@@ -448,8 +447,11 @@ def _resolve_release_log_dir(preferred: Path) -> tuple[Path, str | None]:
448
447
 
449
448
  env_override = os.environ.pop("ARTHEXIS_LOG_DIR", None)
450
449
  fallback = select_log_dir(Path(settings.BASE_DIR))
451
- if env_override and Path(env_override) != fallback:
452
- os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
450
+ if env_override is not None:
451
+ if Path(env_override) == fallback:
452
+ os.environ["ARTHEXIS_LOG_DIR"] = env_override
453
+ else:
454
+ os.environ["ARTHEXIS_LOG_DIR"] = str(fallback)
453
455
 
454
456
  if fallback == preferred:
455
457
  if error:
@@ -608,6 +610,43 @@ def _git_authentication_missing(exc: subprocess.CalledProcessError) -> bool:
608
610
  return any(marker in message for marker in auth_markers)
609
611
 
610
612
 
613
+ def _push_release_changes(log_path: Path) -> bool:
614
+ """Push release commits to ``origin`` and log the outcome."""
615
+
616
+ if not _has_remote("origin"):
617
+ _append_log(
618
+ log_path, "No git remote configured; skipping push of release changes"
619
+ )
620
+ return False
621
+
622
+ try:
623
+ branch = _current_branch()
624
+ if branch is None:
625
+ push_cmd = ["git", "push", "origin", "HEAD"]
626
+ elif _has_upstream(branch):
627
+ push_cmd = ["git", "push"]
628
+ else:
629
+ push_cmd = ["git", "push", "--set-upstream", "origin", branch]
630
+ subprocess.run(push_cmd, check=True, capture_output=True, text=True)
631
+ except subprocess.CalledProcessError as exc:
632
+ details = _format_subprocess_error(exc)
633
+ if _git_authentication_missing(exc):
634
+ _append_log(
635
+ log_path,
636
+ "Authentication is required to push release changes to origin; skipping push",
637
+ )
638
+ if details:
639
+ _append_log(log_path, details)
640
+ return False
641
+ _append_log(
642
+ log_path, f"Failed to push release changes to origin: {details}"
643
+ )
644
+ raise Exception("Failed to push release changes") from exc
645
+
646
+ _append_log(log_path, "Pushed release changes to origin")
647
+ return True
648
+
649
+
611
650
  def _ensure_origin_main_unchanged(log_path: Path) -> None:
612
651
  """Verify that ``origin/main`` has not advanced during the release."""
613
652
 
@@ -653,29 +692,6 @@ def _next_patch_version(version: str) -> str:
653
692
  return f"{parsed.major}.{parsed.minor}.{parsed.micro + 1}"
654
693
 
655
694
 
656
- def _write_todo_fixture(todo: Todo) -> Path:
657
- safe_request = todo.request.replace(".", " ")
658
- slug = slugify(safe_request).replace("-", "_")
659
- if not slug:
660
- slug = "todo"
661
- path = TODO_FIXTURE_DIR / f"todos__{slug}.json"
662
- path.parent.mkdir(parents=True, exist_ok=True)
663
- data = [
664
- {
665
- "model": "core.todo",
666
- "fields": {
667
- "request": todo.request,
668
- "url": todo.url,
669
- "request_details": todo.request_details,
670
- "generated_for_version": todo.generated_for_version,
671
- "generated_for_revision": todo.generated_for_revision,
672
- },
673
- }
674
- ]
675
- path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
676
- return path
677
-
678
-
679
695
  def _should_use_python_changelog(exc: OSError) -> bool:
680
696
  winerror = getattr(exc, "winerror", None)
681
697
  if winerror in {193}:
@@ -696,46 +712,6 @@ def _generate_changelog_with_python(log_path: Path) -> None:
696
712
  _append_log(log_path, "Regenerated CHANGELOG.rst using Python fallback")
697
713
 
698
714
 
699
- def _ensure_release_todo(
700
- release, *, previous_version: str | None = None
701
- ) -> tuple[Todo, Path]:
702
- previous_version = (previous_version or "").strip()
703
- target_version = _next_patch_version(release.version)
704
- if previous_version:
705
- try:
706
- from packaging.version import InvalidVersion, Version
707
-
708
- parsed_previous = Version(previous_version)
709
- parsed_target = Version(target_version)
710
- except InvalidVersion:
711
- pass
712
- else:
713
- if parsed_target <= parsed_previous:
714
- target_version = _next_patch_version(previous_version)
715
- request = f"Create release {release.package.name} {target_version}"
716
- try:
717
- url = reverse("admin:core_packagerelease_changelist")
718
- except NoReverseMatch:
719
- url = ""
720
- todo, _ = Todo.all_objects.update_or_create(
721
- request__iexact=request,
722
- defaults={
723
- "request": request,
724
- "url": url,
725
- "request_details": "",
726
- "generated_for_version": release.version or "",
727
- "generated_for_revision": release.revision or "",
728
- "is_seed_data": True,
729
- "is_deleted": False,
730
- "is_user_data": False,
731
- "done_on": None,
732
- "on_done_condition": "",
733
- },
734
- )
735
- fixture_path = _write_todo_fixture(todo)
736
- return todo, fixture_path
737
-
738
-
739
715
  def _todo_blocks_publish(todo: Todo, release: PackageRelease) -> bool:
740
716
  """Return ``True`` when ``todo`` should block the release workflow."""
741
717
 
@@ -1186,36 +1162,6 @@ def _step_changelog_docs(release, ctx, log_path: Path) -> None:
1186
1162
  _append_log(log_path, "CHANGELOG and documentation review recorded")
1187
1163
 
1188
1164
 
1189
- def _record_release_todo(
1190
- release, ctx, log_path: Path, *, previous_version: str | None = None
1191
- ) -> None:
1192
- previous_version = previous_version or ctx.pop(
1193
- "release_todo_previous_version",
1194
- getattr(release, "_repo_version_before_sync", ""),
1195
- )
1196
- todo, fixture_path = _ensure_release_todo(
1197
- release, previous_version=previous_version
1198
- )
1199
- fixture_display = _format_path(fixture_path)
1200
- _append_log(log_path, f"Added TODO: {todo.request}")
1201
- _append_log(log_path, f"Wrote TODO fixture {fixture_display}")
1202
- subprocess.run(["git", "add", str(fixture_path)], check=True)
1203
- _append_log(log_path, f"Staged TODO fixture {fixture_display}")
1204
- fixture_diff = subprocess.run(
1205
- ["git", "diff", "--cached", "--quiet", "--", str(fixture_path)],
1206
- check=False,
1207
- )
1208
- if fixture_diff.returncode != 0:
1209
- commit_message = f"chore: add release TODO for {release.package.name}"
1210
- subprocess.run(["git", "commit", "-m", commit_message], check=True)
1211
- _append_log(log_path, f"Committed TODO fixture {fixture_display}")
1212
- else:
1213
- _append_log(
1214
- log_path,
1215
- f"No changes detected for TODO fixture {fixture_display}; skipping commit",
1216
- )
1217
-
1218
-
1219
1165
  def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1220
1166
  _append_log(log_path, "Execute pre-release actions")
1221
1167
  if ctx.get("dry_run"):
@@ -1289,7 +1235,6 @@ def _step_pre_release_actions(release, ctx, log_path: Path) -> None:
1289
1235
  for path in staged_release_fixtures:
1290
1236
  subprocess.run(["git", "reset", "HEAD", str(path)], check=False)
1291
1237
  _append_log(log_path, f"Unstaged release fixture {_format_path(path)}")
1292
- ctx["release_todo_previous_version"] = repo_version_before_sync
1293
1238
  _append_log(log_path, "Pre-release actions complete")
1294
1239
 
1295
1240
 
@@ -1340,40 +1285,9 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
1340
1285
  log_path,
1341
1286
  f"Committed release metadata for v{release.version}",
1342
1287
  )
1343
- if _has_remote("origin"):
1344
- try:
1345
- branch = _current_branch()
1346
- if branch is None:
1347
- push_cmd = ["git", "push", "origin", "HEAD"]
1348
- elif _has_upstream(branch):
1349
- push_cmd = ["git", "push"]
1350
- else:
1351
- push_cmd = ["git", "push", "--set-upstream", "origin", branch]
1352
- subprocess.run(push_cmd, check=True, capture_output=True, text=True)
1353
- except subprocess.CalledProcessError as exc:
1354
- details = _format_subprocess_error(exc)
1355
- if _git_authentication_missing(exc):
1356
- _append_log(
1357
- log_path,
1358
- "Authentication is required to push release changes to origin; skipping push",
1359
- )
1360
- if details:
1361
- _append_log(log_path, details)
1362
- else:
1363
- _append_log(
1364
- log_path, f"Failed to push release changes to origin: {details}"
1365
- )
1366
- raise Exception("Failed to push release changes") from exc
1367
- else:
1368
- _append_log(log_path, "Pushed release changes to origin")
1369
- else:
1370
- _append_log(
1371
- log_path,
1372
- "No git remote configured; skipping push of release changes",
1373
- )
1288
+ _push_release_changes(log_path)
1374
1289
  PackageRelease.dump_fixture()
1375
1290
  _append_log(log_path, "Updated release fixtures")
1376
- _record_release_todo(release, ctx, log_path)
1377
1291
  except Exception:
1378
1292
  _clean_repo()
1379
1293
  raise
@@ -1561,6 +1475,30 @@ def _step_publish(release, ctx, log_path: Path) -> None:
1561
1475
  _append_log(log_path, f"Recorded PyPI URL: {release.pypi_url}")
1562
1476
  if release.github_url:
1563
1477
  _append_log(log_path, f"Recorded GitHub URL: {release.github_url}")
1478
+ fixture_paths = [
1479
+ str(path) for path in Path("core/fixtures").glob("releases__*.json")
1480
+ ]
1481
+ if fixture_paths:
1482
+ status = subprocess.run(
1483
+ ["git", "status", "--porcelain", "--", *fixture_paths],
1484
+ capture_output=True,
1485
+ text=True,
1486
+ check=True,
1487
+ )
1488
+ if status.stdout.strip():
1489
+ subprocess.run(["git", "add", *fixture_paths], check=True)
1490
+ _append_log(log_path, "Staged publish metadata updates")
1491
+ commit_message = f"chore: record publish metadata for v{release.version}"
1492
+ subprocess.run(["git", "commit", "-m", commit_message], check=True)
1493
+ _append_log(
1494
+ log_path, f"Committed publish metadata for v{release.version}"
1495
+ )
1496
+ _push_release_changes(log_path)
1497
+ else:
1498
+ _append_log(
1499
+ log_path,
1500
+ "No release metadata updates detected after publish; skipping commit",
1501
+ )
1564
1502
  _append_log(log_path, "Upload complete")
1565
1503
 
1566
1504
 
@@ -1754,9 +1692,9 @@ def rfid_batch(request):
1754
1692
  else:
1755
1693
  post_auth_command = post_auth_command.strip()
1756
1694
 
1757
- tag, _ = RFID.objects.update_or_create(
1758
- rfid=rfid.upper(),
1759
- defaults={
1695
+ tag, _ = RFID.update_or_create_from_code(
1696
+ rfid,
1697
+ {
1760
1698
  "allowed": allowed,
1761
1699
  "color": color,
1762
1700
  "released": released,
nodes/admin.py CHANGED
@@ -8,9 +8,10 @@ from django.contrib.admin import helpers
8
8
  from django.contrib.admin.widgets import FilteredSelectMultiple
9
9
  from django.core.exceptions import PermissionDenied
10
10
  from django.db.models import Count
11
- from django.http import HttpResponse, JsonResponse
11
+ from django.http import Http404, HttpResponse, JsonResponse
12
12
  from django.shortcuts import redirect, render
13
13
  from django.template.response import TemplateResponse
14
+ from django.test import signals
14
15
  from django.urls import NoReverseMatch, path, reverse
15
16
  from django.utils import timezone
16
17
  from django.utils.dateparse import parse_datetime
@@ -233,6 +234,7 @@ class NodeAdmin(EntityModelAdmin):
233
234
  "role",
234
235
  "relation",
235
236
  "last_seen",
237
+ "proxy_link",
236
238
  )
237
239
  search_fields = ("hostname", "address", "mac_address")
238
240
  change_list_template = "admin/nodes/node/change_list.html"
@@ -247,6 +249,7 @@ class NodeAdmin(EntityModelAdmin):
247
249
  "address",
248
250
  "mac_address",
249
251
  "port",
252
+ "message_queue_length",
250
253
  "role",
251
254
  "current_relation",
252
255
  )
@@ -281,6 +284,7 @@ class NodeAdmin(EntityModelAdmin):
281
284
  "register_visitor",
282
285
  "run_task",
283
286
  "take_screenshots",
287
+ "fetch_rfids_from_selected",
284
288
  "import_rfids_from_selected",
285
289
  "export_rfids_to_selected",
286
290
  ]
@@ -290,6 +294,16 @@ class NodeAdmin(EntityModelAdmin):
290
294
  def relation(self, obj):
291
295
  return obj.get_current_relation_display()
292
296
 
297
+ @admin.display(description=_("Proxy"))
298
+ def proxy_link(self, obj):
299
+ if not obj or obj.is_local:
300
+ return ""
301
+ try:
302
+ url = reverse("admin:nodes_node_proxy", args=[obj.pk])
303
+ except NoReverseMatch:
304
+ return ""
305
+ return format_html('<a class="button" href="{}">{}</a>', url, _("Proxy"))
306
+
293
307
  def get_urls(self):
294
308
  urls = super().get_urls()
295
309
  custom = [
@@ -313,6 +327,11 @@ class NodeAdmin(EntityModelAdmin):
313
327
  self.admin_site.admin_view(self.update_selected_progress),
314
328
  name="nodes_node_update_selected_progress",
315
329
  ),
330
+ path(
331
+ "<int:node_id>/proxy/",
332
+ self.admin_site.admin_view(self.proxy_node),
333
+ name="nodes_node_proxy",
334
+ ),
316
335
  ]
317
336
  return custom + urls
318
337
 
@@ -330,7 +349,135 @@ class NodeAdmin(EntityModelAdmin):
330
349
  "token": token,
331
350
  "register_url": reverse("register-node"),
332
351
  }
333
- return render(request, "admin/nodes/node/register_remote.html", context)
352
+ response = TemplateResponse(
353
+ request, "admin/nodes/node/register_remote.html", context
354
+ )
355
+ response.render()
356
+ template = response.resolve_template(response.template_name)
357
+ if getattr(template, "name", None) in (None, ""):
358
+ template.name = response.template_name
359
+ signals.template_rendered.send(
360
+ sender=template.__class__,
361
+ template=template,
362
+ context=response.context_data,
363
+ request=request,
364
+ )
365
+ return response
366
+
367
+ def _load_local_private_key(self, node):
368
+ security_dir = Path(node.base_path or settings.BASE_DIR) / "security"
369
+ priv_path = security_dir / f"{node.public_endpoint}"
370
+ if not priv_path.exists():
371
+ return None, _("Local node private key not found.")
372
+ try:
373
+ return (
374
+ serialization.load_pem_private_key(
375
+ priv_path.read_bytes(), password=None
376
+ ),
377
+ "",
378
+ )
379
+ except Exception as exc: # pragma: no cover - unexpected errors
380
+ return None, str(exc)
381
+
382
+ def _build_proxy_payload(self, request, local_node):
383
+ user = request.user
384
+ payload = {
385
+ "requester": str(local_node.uuid),
386
+ "user": {
387
+ "username": user.get_username(),
388
+ "email": user.email or "",
389
+ "first_name": user.first_name or "",
390
+ "last_name": user.last_name or "",
391
+ "is_staff": user.is_staff,
392
+ "is_superuser": user.is_superuser,
393
+ "groups": list(user.groups.values_list("name", flat=True)),
394
+ "permissions": sorted(user.get_all_permissions()),
395
+ },
396
+ "target": reverse("admin:index"),
397
+ }
398
+ return payload
399
+
400
+ def _start_proxy_session(self, request, node):
401
+ if node.is_local:
402
+ return {"ok": False, "message": _("Local node cannot be proxied.")}
403
+
404
+ local_node = Node.get_local()
405
+ if local_node is None:
406
+ try:
407
+ local_node, _ = Node.register_current()
408
+ except Exception as exc: # pragma: no cover - unexpected errors
409
+ return {"ok": False, "message": str(exc)}
410
+
411
+ private_key, error = self._load_local_private_key(local_node)
412
+ if private_key is None:
413
+ return {"ok": False, "message": error}
414
+
415
+ payload = self._build_proxy_payload(request, local_node)
416
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True)
417
+ try:
418
+ signature = private_key.sign(
419
+ body.encode(),
420
+ padding.PKCS1v15(),
421
+ hashes.SHA256(),
422
+ )
423
+ except Exception as exc: # pragma: no cover - unexpected errors
424
+ return {"ok": False, "message": str(exc)}
425
+
426
+ headers = {
427
+ "Content-Type": "application/json",
428
+ "X-Signature": base64.b64encode(signature).decode(),
429
+ }
430
+
431
+ last_error = ""
432
+ for url in self._iter_remote_urls(node, "/nodes/proxy/session/"):
433
+ try:
434
+ response = requests.post(url, data=body, headers=headers, timeout=5)
435
+ except RequestException as exc:
436
+ last_error = str(exc)
437
+ continue
438
+ if not response.ok:
439
+ last_error = f"{response.status_code} {response.text}"
440
+ continue
441
+ try:
442
+ data = response.json()
443
+ except ValueError:
444
+ last_error = "Invalid JSON response"
445
+ continue
446
+ login_url = data.get("login_url")
447
+ if not login_url:
448
+ last_error = "login_url missing"
449
+ continue
450
+ return {
451
+ "ok": True,
452
+ "login_url": login_url,
453
+ "expires": data.get("expires"),
454
+ }
455
+
456
+ return {
457
+ "ok": False,
458
+ "message": last_error or "Unable to initiate proxy.",
459
+ }
460
+
461
+ def proxy_node(self, request, node_id):
462
+ node = self.get_queryset(request).filter(pk=node_id).first()
463
+ if not node:
464
+ raise Http404
465
+ if not self.has_view_permission(request):
466
+ raise PermissionDenied
467
+ result = self._start_proxy_session(request, node)
468
+ if not result.get("ok"):
469
+ message = result.get("message") or _("Unable to proxy node.")
470
+ self.message_user(request, message, messages.ERROR)
471
+ return redirect("admin:nodes_node_changelist")
472
+
473
+ context = {
474
+ **self.admin_site.each_context(request),
475
+ "opts": self.model._meta,
476
+ "node": node,
477
+ "frame_url": result.get("login_url"),
478
+ "expires": result.get("expires"),
479
+ }
480
+ return TemplateResponse(request, "admin/nodes/node/proxy.html", context)
334
481
 
335
482
  @admin.action(description="Register Visitor")
336
483
  def register_visitor(self, request, queryset=None):
@@ -742,6 +889,7 @@ class NodeAdmin(EntityModelAdmin):
742
889
  def _render_rfid_sync(self, request, operation, results, setup_error=None):
743
890
  titles = {
744
891
  "import": _("Import RFID results"),
892
+ "fetch": _("Fetch RFID results"),
745
893
  "export": _("Export RFID results"),
746
894
  }
747
895
  summary = self._summarize_rfid_results(results)
@@ -851,18 +999,17 @@ class NodeAdmin(EntityModelAdmin):
851
999
  result["status"] = self._status_from_result(result)
852
1000
  return result
853
1001
 
854
- @admin.action(description=_("Import RFIDs from selected"))
855
- def import_rfids_from_selected(self, request, queryset):
1002
+ def _run_rfid_fetch(self, request, queryset, *, operation):
856
1003
  nodes = list(queryset)
857
1004
  local_node, private_key, error = self._load_local_node_credentials()
858
1005
  if error:
859
1006
  results = [self._skip_result(node, error) for node in nodes]
860
- return self._render_rfid_sync(request, "import", results, setup_error=error)
1007
+ return self._render_rfid_sync(request, operation, results, setup_error=error)
861
1008
 
862
1009
  if not nodes:
863
1010
  return self._render_rfid_sync(
864
1011
  request,
865
- "import",
1012
+ operation,
866
1013
  [],
867
1014
  setup_error=_("No nodes selected."),
868
1015
  )
@@ -885,7 +1032,15 @@ class NodeAdmin(EntityModelAdmin):
885
1032
  continue
886
1033
  results.append(self._process_import_from_node(node, payload, headers))
887
1034
 
888
- return self._render_rfid_sync(request, "import", results)
1035
+ return self._render_rfid_sync(request, operation, results)
1036
+
1037
+ @admin.action(description=_("Fetch RFIDs from selected"))
1038
+ def fetch_rfids_from_selected(self, request, queryset):
1039
+ return self._run_rfid_fetch(request, queryset, operation="fetch")
1040
+
1041
+ @admin.action(description=_("Import RFIDs from selected"))
1042
+ def import_rfids_from_selected(self, request, queryset):
1043
+ return self._run_rfid_fetch(request, queryset, operation="import")
889
1044
 
890
1045
  @admin.action(description=_("Export RFIDs to selected"))
891
1046
  def export_rfids_to_selected(self, request, queryset):