arthexis 0.1.12__py3-none-any.whl → 0.1.13__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/views.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import shutil
3
4
  from datetime import timedelta
4
5
 
@@ -8,7 +9,7 @@ from django.contrib.admin.views.decorators import staff_member_required
8
9
  from django.contrib.auth import authenticate, login
9
10
  from django.contrib import messages
10
11
  from django.contrib.sites.models import Site
11
- from django.http import Http404, JsonResponse
12
+ from django.http import Http404, JsonResponse, HttpResponse
12
13
  from django.shortcuts import get_object_or_404, redirect, render, resolve_url
13
14
  from django.utils import timezone
14
15
  from django.utils.text import slugify
@@ -22,9 +23,14 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
22
23
  import errno
23
24
  import subprocess
24
25
 
26
+ from django.template.loader import get_template
27
+ from django.test import signals
28
+
25
29
  from utils import revision
26
30
  from utils.api import api_login_required
27
31
 
32
+ logger = logging.getLogger(__name__)
33
+
28
34
  from .models import Product, EnergyAccount, PackageRelease, Todo
29
35
  from .models import RFID
30
36
 
@@ -44,6 +50,13 @@ def odoo_products(request):
44
50
  {"fields": ["name"], "limit": 50},
45
51
  )
46
52
  except Exception:
53
+ logger.exception(
54
+ "Failed to fetch Odoo products via API for user %s (profile_id=%s, host=%s, database=%s)",
55
+ getattr(request.user, "pk", None),
56
+ getattr(profile, "pk", None),
57
+ getattr(profile, "host", None),
58
+ getattr(profile, "database", None),
59
+ )
47
60
  return JsonResponse({"detail": "Unable to fetch products"}, status=502)
48
61
  items = [{"id": p.get("id"), "name": p.get("name", "")} for p in products]
49
62
  return JsonResponse(items, safe=False)
@@ -77,6 +90,10 @@ def _append_log(path: Path, message: str) -> None:
77
90
  fh.write(message + "\n")
78
91
 
79
92
 
93
+ def _release_log_name(package_name: str, version: str) -> str:
94
+ return f"pr.{package_name}.v{version}.log"
95
+
96
+
80
97
  def _clean_repo() -> None:
81
98
  """Return the git repository to a clean state."""
82
99
  subprocess.run(["git", "reset", "--hard"], check=False)
@@ -599,9 +616,14 @@ def _step_promote_build(release, ctx, log_path: Path) -> None:
599
616
  except Exception:
600
617
  _clean_repo()
601
618
  raise
602
- release_name = f"{release.package.name}-{release.version}"
603
- new_log = log_path.with_name(f"{release_name}.log")
604
- log_path.rename(new_log)
619
+ target_name = _release_log_name(release.package.name, release.version)
620
+ new_log = log_path.with_name(target_name)
621
+ if log_path != new_log:
622
+ if new_log.exists():
623
+ new_log.unlink()
624
+ log_path.rename(new_log)
625
+ else:
626
+ new_log = log_path
605
627
  ctx["log"] = new_log.name
606
628
  _append_log(new_log, "Build complete")
607
629
 
@@ -880,9 +902,8 @@ def release_progress(request, pk: int, action: str):
880
902
  if restart_path.exists():
881
903
  restart_path.unlink()
882
904
  log_dir = Path("logs")
883
- for log_file in log_dir.glob(
884
- f"{release.package.name}-{previous_version}*.log"
885
- ):
905
+ pattern = f"pr.{release.package.name}.v{previous_version}*.log"
906
+ for log_file in log_dir.glob(pattern):
886
907
  log_file.unlink()
887
908
  if not release.is_current:
888
909
  raise Http404("Release is not current")
@@ -904,7 +925,8 @@ def release_progress(request, pk: int, action: str):
904
925
  if lock_path.exists():
905
926
  lock_path.unlink()
906
927
  log_dir = Path("logs")
907
- for f in log_dir.glob(f"{release.package.name}-{release.version}*.log"):
928
+ pattern = f"pr.{release.package.name}.v{release.version}*.log"
929
+ for f in log_dir.glob(pattern):
908
930
  f.unlink()
909
931
  return redirect(request.path)
910
932
  ctx = request.session.get(session_key)
@@ -979,8 +1001,7 @@ def release_progress(request, pk: int, action: str):
979
1001
  else:
980
1002
  ctx.pop("todos", None)
981
1003
 
982
- identifier = f"{release.package.name}-{release.version}"
983
- log_name = f"{identifier}.log"
1004
+ log_name = _release_log_name(release.package.name, release.version)
984
1005
  if ctx.get("log") != log_name:
985
1006
  ctx = {
986
1007
  "step": 0,
@@ -1186,7 +1207,18 @@ def release_progress(request, pk: int, action: str):
1186
1207
  else:
1187
1208
  lock_path.parent.mkdir(parents=True, exist_ok=True)
1188
1209
  lock_path.write_text(json.dumps(ctx), encoding="utf-8")
1189
- return render(request, "core/release_progress.html", context)
1210
+ template = get_template("core/release_progress.html")
1211
+ content = template.render(context, request)
1212
+ signals.template_rendered.send(
1213
+ sender=template.__class__,
1214
+ template=template,
1215
+ context=context,
1216
+ using=getattr(getattr(template, "engine", None), "name", None),
1217
+ )
1218
+ response = HttpResponse(content)
1219
+ response.context = context
1220
+ response.templates = [template]
1221
+ return response
1190
1222
 
1191
1223
 
1192
1224
  def _dedupe_preserve_order(values):
@@ -1371,8 +1403,11 @@ def todo_focus(request, pk: int):
1371
1403
  @staff_member_required
1372
1404
  @require_POST
1373
1405
  def todo_done(request, pk: int):
1374
- todo = get_object_or_404(Todo, pk=pk, is_deleted=False, done_on__isnull=True)
1375
1406
  redirect_to = _get_return_url(request)
1407
+ try:
1408
+ todo = Todo.objects.get(pk=pk, is_deleted=False, done_on__isnull=True)
1409
+ except Todo.DoesNotExist:
1410
+ return redirect(redirect_to)
1376
1411
  result = todo.check_on_done_condition()
1377
1412
  if not result.passed:
1378
1413
  messages.error(request, _format_condition_failure(todo, result))
core/widgets.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from django import forms
2
+ from django.forms.widgets import ClearableFileInput
2
3
  import json
3
4
 
4
5
 
@@ -49,3 +50,45 @@ class OdooProductWidget(forms.Select):
49
50
  return json.loads(raw)
50
51
  except Exception:
51
52
  return {}
53
+
54
+
55
+ class AdminBase64FileWidget(ClearableFileInput):
56
+ """Clearable file input that exposes base64 data for downloads."""
57
+
58
+ template_name = "widgets/admin_base64_file.html"
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ download_name: str | None = None,
64
+ content_type: str = "application/octet-stream",
65
+ **kwargs,
66
+ ) -> None:
67
+ self.download_name = download_name
68
+ self.content_type = content_type
69
+ super().__init__(**kwargs)
70
+
71
+ def is_initial(self, value):
72
+ if isinstance(value, str):
73
+ return bool(value)
74
+ return super().is_initial(value)
75
+
76
+ def format_value(self, value):
77
+ if isinstance(value, str):
78
+ return value
79
+ return super().format_value(value)
80
+
81
+ def get_context(self, name, value, attrs):
82
+ if isinstance(value, str):
83
+ base64_value = value.strip()
84
+ rendered_value = None
85
+ else:
86
+ base64_value = None
87
+ rendered_value = value
88
+ context = super().get_context(name, rendered_value, attrs)
89
+ widget_context = context["widget"]
90
+ widget_context["is_initial"] = bool(base64_value)
91
+ widget_context["base64_value"] = base64_value
92
+ widget_context["download_name"] = self.download_name or f"{name}.bin"
93
+ widget_context["content_type"] = self.content_type
94
+ return context
nodes/admin.py CHANGED
@@ -2,7 +2,7 @@ from django.contrib import admin, messages
2
2
  from django.urls import NoReverseMatch, path, reverse
3
3
  from django.shortcuts import redirect, render
4
4
  from django.template.response import TemplateResponse
5
- from django.utils.html import format_html
5
+ from django.utils.html import format_html, format_html_join
6
6
  from django import forms
7
7
  from django.contrib.admin.widgets import FilteredSelectMultiple
8
8
  from core.widgets import CopyColorWidget
@@ -12,11 +12,20 @@ from pathlib import Path
12
12
  from django.http import HttpResponse
13
13
  from django.utils import timezone
14
14
  from django.utils.translation import gettext_lazy as _
15
+ from urllib.parse import urlsplit, urlunsplit
16
+ from django.core.exceptions import PermissionDenied
17
+ from django.utils.dateparse import parse_datetime
15
18
  import base64
19
+ import json
16
20
  import pyperclip
17
21
  from pyperclip import PyperclipException
18
22
  import uuid
19
23
  import subprocess
24
+
25
+ import requests
26
+ from requests import RequestException
27
+ from cryptography.hazmat.primitives import hashes, serialization
28
+ from cryptography.hazmat.primitives.asymmetric import padding
20
29
  from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
21
30
  from .actions import NodeAction
22
31
  from .reports import (
@@ -39,6 +48,7 @@ from .models import (
39
48
  DNSRecord,
40
49
  )
41
50
  from . import dns as dns_utils
51
+ from core.models import RFID
42
52
  from core.user_data import EntityModelAdmin
43
53
 
44
54
 
@@ -210,7 +220,12 @@ class NodeAdmin(EntityModelAdmin):
210
220
  change_list_template = "admin/nodes/node/change_list.html"
211
221
  change_form_template = "admin/nodes/node/change_form.html"
212
222
  form = NodeAdminForm
213
- actions = ["register_visitor", "run_task", "take_screenshots"]
223
+ actions = [
224
+ "register_visitor",
225
+ "run_task",
226
+ "take_screenshots",
227
+ "fetch_rfids",
228
+ ]
214
229
  inlines = [NodeFeatureAssignmentInline]
215
230
 
216
231
  def get_urls(self):
@@ -241,6 +256,8 @@ class NodeAdmin(EntityModelAdmin):
241
256
 
242
257
  def register_current(self, request):
243
258
  """Create or update this host and offer browser node registration."""
259
+ if not request.user.is_superuser:
260
+ raise PermissionDenied
244
261
  node, created = Node.register_current()
245
262
  if created:
246
263
  self.message_user(
@@ -346,6 +363,151 @@ class NodeAdmin(EntityModelAdmin):
346
363
  count += 1
347
364
  self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
348
365
 
366
+ @admin.action(description="Fetch RFIDs from selected")
367
+ def fetch_rfids(self, request, queryset):
368
+ local_node = Node.get_local()
369
+ if not local_node:
370
+ self.message_user(
371
+ request,
372
+ "Local node is not registered.",
373
+ messages.ERROR,
374
+ )
375
+ return None
376
+
377
+ security_dir = Path(local_node.base_path or settings.BASE_DIR) / "security"
378
+ priv_path = security_dir / f"{local_node.public_endpoint}"
379
+ if not priv_path.exists():
380
+ self.message_user(
381
+ request,
382
+ "Local node private key not found.",
383
+ messages.ERROR,
384
+ )
385
+ return None
386
+
387
+ try:
388
+ private_key = serialization.load_pem_private_key(
389
+ priv_path.read_bytes(), password=None
390
+ )
391
+ except Exception as exc: # pragma: no cover - unexpected key errors
392
+ self.message_user(
393
+ request,
394
+ f"Failed to load private key: {exc}",
395
+ messages.ERROR,
396
+ )
397
+ return None
398
+
399
+ payload = json.dumps(
400
+ {"requester": str(local_node.uuid)},
401
+ separators=(",", ":"),
402
+ sort_keys=True,
403
+ )
404
+ signature = base64.b64encode(
405
+ private_key.sign(
406
+ payload.encode(),
407
+ padding.PKCS1v15(),
408
+ hashes.SHA256(),
409
+ )
410
+ ).decode()
411
+ headers = {
412
+ "Content-Type": "application/json",
413
+ "X-Signature": signature,
414
+ }
415
+
416
+ processed = 0
417
+ total_created = 0
418
+ total_updated = 0
419
+ errors = 0
420
+
421
+ for node in queryset:
422
+ if local_node.pk and node.pk == local_node.pk:
423
+ continue
424
+ url = f"http://{node.address}:{node.port}/nodes/rfid/export/"
425
+ try:
426
+ response = requests.post(
427
+ url,
428
+ data=payload,
429
+ headers=headers,
430
+ timeout=5,
431
+ )
432
+ except RequestException as exc:
433
+ self.message_user(request, f"{node}: {exc}", messages.ERROR)
434
+ errors += 1
435
+ continue
436
+
437
+ if response.status_code != 200:
438
+ self.message_user(
439
+ request,
440
+ f"{node}: {response.status_code} {response.text}",
441
+ messages.ERROR,
442
+ )
443
+ errors += 1
444
+ continue
445
+
446
+ try:
447
+ data = response.json()
448
+ except ValueError:
449
+ self.message_user(
450
+ request,
451
+ f"{node}: invalid JSON response",
452
+ messages.ERROR,
453
+ )
454
+ errors += 1
455
+ continue
456
+
457
+ created = 0
458
+ updated = 0
459
+ rfids = data.get("rfids", []) or []
460
+ for entry in rfids:
461
+ rfid_value = entry.get("rfid")
462
+ if not rfid_value:
463
+ continue
464
+ defaults = {
465
+ "custom_label": entry.get("custom_label", ""),
466
+ "key_a": entry.get(
467
+ "key_a", RFID._meta.get_field("key_a").default
468
+ ),
469
+ "key_b": entry.get(
470
+ "key_b", RFID._meta.get_field("key_b").default
471
+ ),
472
+ "data": entry.get("data", []),
473
+ "key_a_verified": bool(entry.get("key_a_verified", False)),
474
+ "key_b_verified": bool(entry.get("key_b_verified", False)),
475
+ "allowed": bool(entry.get("allowed", True)),
476
+ "color": entry.get("color", RFID.BLACK),
477
+ "kind": entry.get("kind", RFID.CLASSIC),
478
+ "released": bool(entry.get("released", False)),
479
+ "origin_node": node,
480
+ }
481
+ if "last_seen_on" in entry:
482
+ last_seen_raw = entry.get("last_seen_on")
483
+ if last_seen_raw:
484
+ defaults["last_seen_on"] = parse_datetime(last_seen_raw)
485
+ else:
486
+ defaults["last_seen_on"] = None
487
+
488
+ obj, created_flag = RFID.objects.update_or_create(
489
+ rfid=rfid_value,
490
+ defaults=defaults,
491
+ )
492
+ if created_flag:
493
+ created += 1
494
+ else:
495
+ updated += 1
496
+
497
+ processed += 1
498
+ total_created += created
499
+ total_updated += updated
500
+
501
+ if processed:
502
+ message = (
503
+ f"Fetched RFIDs from {processed} node(s); "
504
+ f"{total_created} created, {total_updated} updated."
505
+ )
506
+ level = messages.SUCCESS if not errors else messages.WARNING
507
+ self.message_user(request, message, level)
508
+ elif not errors:
509
+ self.message_user(request, "No remote nodes selected.", messages.INFO)
510
+
349
511
  def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
350
512
  extra_context = extra_context or {}
351
513
  extra_context["node_actions"] = NodeAction.get_actions()
@@ -505,7 +667,7 @@ class NodeFeatureAdmin(EntityModelAdmin):
505
667
  "slug",
506
668
  "default_roles",
507
669
  "is_enabled_display",
508
- "default_action",
670
+ "available_actions",
509
671
  )
510
672
  actions = ["check_features_for_eligibility", "enable_selected_features"]
511
673
  readonly_fields = ("is_enabled",)
@@ -524,18 +686,26 @@ class NodeFeatureAdmin(EntityModelAdmin):
524
686
  def is_enabled_display(self, obj):
525
687
  return obj.is_enabled
526
688
 
527
- @admin.display(description="Default Action")
528
- def default_action(self, obj):
689
+ @admin.display(description="Actions")
690
+ def available_actions(self, obj):
529
691
  if not obj.is_enabled:
530
692
  return "—"
531
- action = obj.get_default_action()
532
- if not action:
693
+ actions = obj.get_default_actions()
694
+ if not actions:
533
695
  return "—"
534
- try:
535
- url = reverse(action.url_name)
536
- except NoReverseMatch:
537
- return action.label
538
- return format_html('<a href="{}">{}</a>', url, action.label)
696
+
697
+ links = []
698
+ for action in actions:
699
+ try:
700
+ url = reverse(action.url_name)
701
+ except NoReverseMatch:
702
+ links.append(action.label)
703
+ else:
704
+ links.append(format_html('<a href="{}">{}</a>', url, action.label))
705
+
706
+ if not links:
707
+ return "—"
708
+ return format_html_join(" | ", "{}", ((link,) for link in links))
539
709
 
540
710
  def _manual_enablement_message(self, feature, node):
541
711
  if node is None:
@@ -667,6 +837,11 @@ class NodeFeatureAdmin(EntityModelAdmin):
667
837
  self.admin_site.admin_view(self.take_snapshot),
668
838
  name="nodes_nodefeature_take_snapshot",
669
839
  ),
840
+ path(
841
+ "view-stream/",
842
+ self.admin_site.admin_view(self.view_stream),
843
+ name="nodes_nodefeature_view_stream",
844
+ ),
670
845
  ]
671
846
  return custom + urls
672
847
 
@@ -795,6 +970,33 @@ class NodeFeatureAdmin(EntityModelAdmin):
795
970
  return redirect("..")
796
971
  return redirect(change_url)
797
972
 
973
+ def view_stream(self, request):
974
+ feature = self._ensure_feature_enabled(request, "rpi-camera", "View stream")
975
+ if not feature:
976
+ return redirect("..")
977
+
978
+ configured_stream = getattr(settings, "RPI_CAMERA_STREAM_URL", "").strip()
979
+ if configured_stream:
980
+ stream_url = configured_stream
981
+ else:
982
+ base_uri = request.build_absolute_uri("/")
983
+ parsed = urlsplit(base_uri)
984
+ hostname = parsed.hostname or "127.0.0.1"
985
+ port = getattr(settings, "RPI_CAMERA_STREAM_PORT", 8554)
986
+ scheme = getattr(settings, "RPI_CAMERA_STREAM_SCHEME", "http")
987
+ netloc = f"{hostname}:{port}" if port else hostname
988
+ stream_url = urlunsplit((scheme, netloc, "/", "", ""))
989
+ context = {
990
+ **self.admin_site.each_context(request),
991
+ "title": _("Raspberry Pi Camera Stream"),
992
+ "stream_url": stream_url,
993
+ }
994
+ return TemplateResponse(
995
+ request,
996
+ "admin/nodes/nodefeature/view_stream.html",
997
+ context,
998
+ )
999
+
798
1000
 
799
1001
  @admin.register(ContentSample)
800
1002
  class ContentSampleAdmin(EntityModelAdmin):
@@ -876,19 +1078,80 @@ class ContentSampleAdmin(EntityModelAdmin):
876
1078
 
877
1079
  @admin.register(NetMessage)
878
1080
  class NetMessageAdmin(EntityModelAdmin):
1081
+ class NetMessageAdminForm(forms.ModelForm):
1082
+ class Meta:
1083
+ model = NetMessage
1084
+ fields = "__all__"
1085
+ widgets = {"body": forms.Textarea(attrs={"rows": 4})}
1086
+
1087
+ form = NetMessageAdminForm
1088
+ change_form_template = "admin/nodes/netmessage/change_form.html"
879
1089
  list_display = (
880
1090
  "subject",
881
1091
  "body",
882
- "reach",
1092
+ "filter_node",
1093
+ "filter_node_role",
883
1094
  "node_origin",
884
1095
  "created",
1096
+ "target_limit",
885
1097
  "complete",
886
1098
  )
887
1099
  search_fields = ("subject", "body")
888
- list_filter = ("complete", "reach")
1100
+ list_filter = ("complete", "filter_node_role", "filter_current_relation")
889
1101
  ordering = ("-created",)
890
1102
  readonly_fields = ("complete",)
891
1103
  actions = ["send_messages"]
1104
+ fieldsets = (
1105
+ (None, {"fields": ("subject", "body")}),
1106
+ (
1107
+ "Filters",
1108
+ {
1109
+ "fields": (
1110
+ "filter_node",
1111
+ "filter_node_feature",
1112
+ "filter_node_role",
1113
+ "filter_current_relation",
1114
+ "filter_installed_version",
1115
+ "filter_installed_revision",
1116
+ )
1117
+ },
1118
+ ),
1119
+ (
1120
+ "Propagation",
1121
+ {
1122
+ "fields": (
1123
+ "node_origin",
1124
+ "target_limit",
1125
+ "propagated_to",
1126
+ "complete",
1127
+ )
1128
+ },
1129
+ ),
1130
+ )
1131
+
1132
+ def get_changeform_initial_data(self, request):
1133
+ initial = super().get_changeform_initial_data(request)
1134
+ initial = dict(initial) if initial else {}
1135
+ reply_to = request.GET.get("reply_to")
1136
+ if reply_to:
1137
+ try:
1138
+ message = (
1139
+ NetMessage.objects.select_related("node_origin__role")
1140
+ .get(pk=reply_to)
1141
+ )
1142
+ except (NetMessage.DoesNotExist, ValueError, TypeError):
1143
+ message = None
1144
+ if message:
1145
+ subject = (message.subject or "").strip()
1146
+ if subject:
1147
+ if not subject.lower().startswith("re:"):
1148
+ subject = f"Re: {subject}"
1149
+ else:
1150
+ subject = "Re:"
1151
+ initial.setdefault("subject", subject[:64])
1152
+ if message.node_origin and "filter_node" not in initial:
1153
+ initial["filter_node"] = message.node_origin.pk
1154
+ return initial
892
1155
 
893
1156
  def send_messages(self, request, queryset):
894
1157
  for msg in queryset:
nodes/apps.py CHANGED
@@ -31,6 +31,21 @@ def _startup_notification() -> None:
31
31
  rev_short = revision_value[-6:] if revision_value else ""
32
32
 
33
33
  body = version
34
+ if body:
35
+ normalized = body.lstrip("vV") or body
36
+ base_version = normalized.rstrip("+")
37
+ needs_marker = False
38
+ if base_version and revision_value:
39
+ try: # pragma: no cover - defensive guard
40
+ from core.models import PackageRelease
41
+
42
+ needs_marker = not PackageRelease.matches_revision(
43
+ base_version, revision_value
44
+ )
45
+ except Exception:
46
+ logger.debug("Startup release comparison failed", exc_info=True)
47
+ if needs_marker and not normalized.endswith("+"):
48
+ body = f"{body}+"
34
49
  if rev_short:
35
50
  body = f"{body} r{rev_short}" if body else f"r{rev_short}"
36
51