arthexis 0.1.11__py3-none-any.whl → 0.1.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

nodes/admin.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from django.contrib import admin, messages
2
- from django.urls import path, reverse
2
+ from django.urls import NoReverseMatch, path, reverse
3
3
  from django.shortcuts import redirect, render
4
+ from django.template.response import TemplateResponse
4
5
  from django.utils.html import format_html
5
6
  from django import forms
6
7
  from django.contrib.admin.widgets import FilteredSelectMultiple
@@ -9,15 +10,23 @@ from django.db.models import Count
9
10
  from django.conf import settings
10
11
  from pathlib import Path
11
12
  from django.http import HttpResponse
13
+ from django.utils import timezone
12
14
  from django.utils.translation import gettext_lazy as _
13
15
  import base64
14
16
  import pyperclip
15
17
  from pyperclip import PyperclipException
16
18
  import uuid
17
19
  import subprocess
18
- from .utils import capture_screenshot, save_screenshot
20
+ from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
19
21
  from .actions import NodeAction
22
+ from .reports import (
23
+ collect_celery_log_entries,
24
+ collect_scheduled_tasks,
25
+ iter_report_periods,
26
+ resolve_period,
27
+ )
20
28
 
29
+ from core.admin import EmailOutboxAdminForm
21
30
  from .models import (
22
31
  Node,
23
32
  EmailOutbox,
@@ -69,6 +78,24 @@ class NodeManagerAdmin(EntityModelAdmin):
69
78
  "user__username",
70
79
  "group__name",
71
80
  )
81
+ fieldsets = (
82
+ (_("Owner"), {"fields": ("user", "group")}),
83
+ (
84
+ _("Credentials"),
85
+ {"fields": ("api_key", "api_secret", "customer_id")},
86
+ ),
87
+ (
88
+ _("Configuration"),
89
+ {
90
+ "fields": (
91
+ "provider",
92
+ "default_domain",
93
+ "use_sandbox",
94
+ "is_enabled",
95
+ )
96
+ },
97
+ ),
98
+ )
72
99
 
73
100
 
74
101
  @admin.register(DNSRecord)
@@ -358,6 +385,7 @@ class NodeAdmin(EntityModelAdmin):
358
385
 
359
386
  @admin.register(EmailOutbox)
360
387
  class EmailOutboxAdmin(EntityModelAdmin):
388
+ form = EmailOutboxAdminForm
361
389
  list_display = (
362
390
  "owner_label",
363
391
  "host",
@@ -369,15 +397,15 @@ class EmailOutboxAdmin(EntityModelAdmin):
369
397
  )
370
398
  change_form_template = "admin/nodes/emailoutbox/change_form.html"
371
399
  fieldsets = (
372
- ("Owner", {"fields": ("user", "group", "node")}),
400
+ ("Owner", {"fields": ("user", "group")}),
401
+ ("Credentials", {"fields": ("username", "password")}),
373
402
  (
374
403
  "Configuration",
375
404
  {
376
405
  "fields": (
406
+ "node",
377
407
  "host",
378
408
  "port",
379
- "username",
380
- "password",
381
409
  "use_tls",
382
410
  "use_ssl",
383
411
  "from_email",
@@ -472,7 +500,14 @@ class NodeRoleAdmin(EntityModelAdmin):
472
500
  @admin.register(NodeFeature)
473
501
  class NodeFeatureAdmin(EntityModelAdmin):
474
502
  filter_horizontal = ("roles",)
475
- list_display = ("display", "slug", "default_roles", "is_enabled")
503
+ list_display = (
504
+ "display",
505
+ "slug",
506
+ "default_roles",
507
+ "is_enabled_display",
508
+ "default_action",
509
+ )
510
+ actions = ["check_features_for_eligibility", "enable_selected_features"]
476
511
  readonly_fields = ("is_enabled",)
477
512
  search_fields = ("display", "slug")
478
513
 
@@ -485,6 +520,281 @@ class NodeFeatureAdmin(EntityModelAdmin):
485
520
  roles = [role.name for role in obj.roles.all()]
486
521
  return ", ".join(roles) if roles else "—"
487
522
 
523
+ @admin.display(description="Is Enabled", boolean=True, ordering="is_enabled")
524
+ def is_enabled_display(self, obj):
525
+ return obj.is_enabled
526
+
527
+ @admin.display(description="Default Action")
528
+ def default_action(self, obj):
529
+ if not obj.is_enabled:
530
+ return "—"
531
+ action = obj.get_default_action()
532
+ if not action:
533
+ 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)
539
+
540
+ def _manual_enablement_message(self, feature, node):
541
+ if node is None:
542
+ return (
543
+ "Manual enablement is unavailable without a registered local node."
544
+ )
545
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS:
546
+ return "This feature can be enabled manually."
547
+ return "This feature cannot be enabled manually."
548
+
549
+ @admin.action(description="Check features for eligibility")
550
+ def check_features_for_eligibility(self, request, queryset):
551
+ from .feature_checks import feature_checks
552
+
553
+ features = list(queryset)
554
+ total = len(features)
555
+ successes = 0
556
+ node = Node.get_local()
557
+ for feature in features:
558
+ enablement_message = self._manual_enablement_message(feature, node)
559
+ try:
560
+ result = feature_checks.run(feature, node=node)
561
+ except Exception as exc: # pragma: no cover - defensive
562
+ self.message_user(
563
+ request,
564
+ f"{feature.display}: {exc} {enablement_message}",
565
+ level=messages.ERROR,
566
+ )
567
+ continue
568
+ if result is None:
569
+ self.message_user(
570
+ request,
571
+ f"No check is configured for {feature.display}. {enablement_message}",
572
+ level=messages.WARNING,
573
+ )
574
+ continue
575
+ message = result.message or (
576
+ f"{feature.display} check {'passed' if result.success else 'failed'}."
577
+ )
578
+ self.message_user(
579
+ request, f"{message} {enablement_message}", level=result.level
580
+ )
581
+ if result.success:
582
+ successes += 1
583
+ if total:
584
+ self.message_user(
585
+ request,
586
+ f"Completed {successes} of {total} feature check(s) successfully.",
587
+ level=messages.INFO,
588
+ )
589
+
590
+ @admin.action(description="Enable selected action")
591
+ def enable_selected_features(self, request, queryset):
592
+ node = Node.get_local()
593
+ if node is None:
594
+ self.message_user(
595
+ request,
596
+ "No local node is registered; unable to enable features manually.",
597
+ level=messages.ERROR,
598
+ )
599
+ return
600
+
601
+ manual_features = [
602
+ feature
603
+ for feature in queryset
604
+ if feature.slug in Node.MANUAL_FEATURE_SLUGS
605
+ ]
606
+ non_manual_features = [
607
+ feature
608
+ for feature in queryset
609
+ if feature.slug not in Node.MANUAL_FEATURE_SLUGS
610
+ ]
611
+ for feature in non_manual_features:
612
+ self.message_user(
613
+ request,
614
+ f"{feature.display} cannot be enabled manually.",
615
+ level=messages.WARNING,
616
+ )
617
+
618
+ if not manual_features:
619
+ self.message_user(
620
+ request,
621
+ "None of the selected features can be enabled manually.",
622
+ level=messages.WARNING,
623
+ )
624
+ return
625
+
626
+ current_manual = set(
627
+ node.features.filter(slug__in=Node.MANUAL_FEATURE_SLUGS).values_list(
628
+ "slug", flat=True
629
+ )
630
+ )
631
+ desired_manual = current_manual | {feature.slug for feature in manual_features}
632
+ newly_enabled = desired_manual - current_manual
633
+ if not newly_enabled:
634
+ self.message_user(
635
+ request,
636
+ "Selected manual features are already enabled.",
637
+ level=messages.INFO,
638
+ )
639
+ return
640
+
641
+ node.update_manual_features(desired_manual)
642
+ display_map = {feature.slug: feature.display for feature in manual_features}
643
+ newly_enabled_names = [display_map[slug] for slug in sorted(newly_enabled)]
644
+ self.message_user(
645
+ request,
646
+ "Enabled {} feature(s): {}".format(
647
+ len(newly_enabled), ", ".join(newly_enabled_names)
648
+ ),
649
+ level=messages.SUCCESS,
650
+ )
651
+
652
+ def get_urls(self):
653
+ urls = super().get_urls()
654
+ custom = [
655
+ path(
656
+ "celery-report/",
657
+ self.admin_site.admin_view(self.celery_report),
658
+ name="nodes_nodefeature_celery_report",
659
+ ),
660
+ path(
661
+ "take-screenshot/",
662
+ self.admin_site.admin_view(self.take_screenshot),
663
+ name="nodes_nodefeature_take_screenshot",
664
+ ),
665
+ path(
666
+ "take-snapshot/",
667
+ self.admin_site.admin_view(self.take_snapshot),
668
+ name="nodes_nodefeature_take_snapshot",
669
+ ),
670
+ ]
671
+ return custom + urls
672
+
673
+ def celery_report(self, request):
674
+ period = resolve_period(request.GET.get("period"))
675
+ now = timezone.now()
676
+ window_end = now + period.delta
677
+ log_window_start = now - period.delta
678
+
679
+ scheduled_tasks = collect_scheduled_tasks(now, window_end)
680
+ log_collection = collect_celery_log_entries(log_window_start, now)
681
+
682
+ period_options = [
683
+ {
684
+ "key": candidate.key,
685
+ "label": candidate.label,
686
+ "selected": candidate.key == period.key,
687
+ "url": f"?period={candidate.key}",
688
+ }
689
+ for candidate in iter_report_periods()
690
+ ]
691
+
692
+ context = {
693
+ **self.admin_site.each_context(request),
694
+ "title": _("Celery Report"),
695
+ "period": period,
696
+ "period_options": period_options,
697
+ "current_time": now,
698
+ "window_end": window_end,
699
+ "log_window_start": log_window_start,
700
+ "scheduled_tasks": scheduled_tasks,
701
+ "log_entries": log_collection.entries,
702
+ "log_sources": log_collection.checked_sources,
703
+ }
704
+ return TemplateResponse(
705
+ request,
706
+ "admin/nodes/nodefeature/celery_report.html",
707
+ context,
708
+ )
709
+
710
+ def _ensure_feature_enabled(self, request, slug: str, action_label: str):
711
+ try:
712
+ feature = NodeFeature.objects.get(slug=slug)
713
+ except NodeFeature.DoesNotExist:
714
+ self.message_user(
715
+ request,
716
+ f"{action_label} is unavailable because the feature is not configured.",
717
+ level=messages.ERROR,
718
+ )
719
+ return None
720
+ if not feature.is_enabled:
721
+ self.message_user(
722
+ request,
723
+ f"{feature.display} feature is not enabled on this node.",
724
+ level=messages.WARNING,
725
+ )
726
+ return None
727
+ return feature
728
+
729
+ def take_screenshot(self, request):
730
+ feature = self._ensure_feature_enabled(
731
+ request, "screenshot-poll", "Take Screenshot"
732
+ )
733
+ if not feature:
734
+ return redirect("..")
735
+ url = request.build_absolute_uri("/")
736
+ try:
737
+ path = capture_screenshot(url)
738
+ except Exception as exc: # pragma: no cover - depends on selenium setup
739
+ self.message_user(request, str(exc), level=messages.ERROR)
740
+ return redirect("..")
741
+ node = Node.get_local()
742
+ sample = save_screenshot(path, node=node, method="DEFAULT_ACTION")
743
+ if not sample:
744
+ self.message_user(
745
+ request, "Duplicate screenshot; not saved", level=messages.INFO
746
+ )
747
+ return redirect("..")
748
+ self.message_user(
749
+ request, f"Screenshot saved to {sample.path}", level=messages.SUCCESS
750
+ )
751
+ try:
752
+ change_url = reverse(
753
+ "admin:nodes_contentsample_change", args=[sample.pk]
754
+ )
755
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
756
+ self.message_user(
757
+ request,
758
+ "Screenshot saved but the admin page could not be resolved.",
759
+ level=messages.WARNING,
760
+ )
761
+ return redirect("..")
762
+ return redirect(change_url)
763
+
764
+ def take_snapshot(self, request):
765
+ feature = self._ensure_feature_enabled(
766
+ request, "rpi-camera", "Take a Snapshot"
767
+ )
768
+ if not feature:
769
+ return redirect("..")
770
+ try:
771
+ path = capture_rpi_snapshot()
772
+ except Exception as exc: # pragma: no cover - depends on camera stack
773
+ self.message_user(request, str(exc), level=messages.ERROR)
774
+ return redirect("..")
775
+ node = Node.get_local()
776
+ sample = save_screenshot(path, node=node, method="RPI_CAMERA")
777
+ if not sample:
778
+ self.message_user(
779
+ request, "Duplicate snapshot; not saved", level=messages.INFO
780
+ )
781
+ return redirect("..")
782
+ self.message_user(
783
+ request, f"Snapshot saved to {sample.path}", level=messages.SUCCESS
784
+ )
785
+ try:
786
+ change_url = reverse(
787
+ "admin:nodes_contentsample_change", args=[sample.pk]
788
+ )
789
+ except NoReverseMatch: # pragma: no cover - admin URL always registered
790
+ self.message_user(
791
+ request,
792
+ "Snapshot saved but the admin page could not be resolved.",
793
+ level=messages.WARNING,
794
+ )
795
+ return redirect("..")
796
+ return redirect(change_url)
797
+
488
798
 
489
799
  @admin.register(ContentSample)
490
800
  class ContentSampleAdmin(EntityModelAdmin):
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Dict, Iterable, Optional
5
+
6
+ from django.contrib import messages
7
+
8
+ if False: # pragma: no cover - typing imports only
9
+ from .models import Node, NodeFeature
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class FeatureCheckResult:
14
+ """Outcome of a feature validation."""
15
+
16
+ success: bool
17
+ message: str
18
+ level: int = messages.INFO
19
+
20
+
21
+ FeatureCheck = Callable[["NodeFeature", Optional["Node"]], Any]
22
+
23
+
24
+ class FeatureCheckRegistry:
25
+ """Registry for feature validation callbacks."""
26
+
27
+ def __init__(self) -> None:
28
+ self._checks: Dict[str, FeatureCheck] = {}
29
+ self._default_check: Optional[FeatureCheck] = None
30
+
31
+ def register(self, slug: str) -> Callable[[FeatureCheck], FeatureCheck]:
32
+ """Register ``func`` as the validator for ``slug``."""
33
+
34
+ def decorator(func: FeatureCheck) -> FeatureCheck:
35
+ self._checks[slug] = func
36
+ return func
37
+
38
+ return decorator
39
+
40
+ def register_default(self, func: FeatureCheck) -> FeatureCheck:
41
+ """Register ``func`` as the fallback validator."""
42
+
43
+ self._default_check = func
44
+ return func
45
+
46
+ def get(self, slug: str) -> Optional[FeatureCheck]:
47
+ return self._checks.get(slug)
48
+
49
+ def items(self) -> Iterable[tuple[str, FeatureCheck]]:
50
+ return self._checks.items()
51
+
52
+ def run(
53
+ self, feature: "NodeFeature", *, node: Optional["Node"] = None
54
+ ) -> Optional[FeatureCheckResult]:
55
+ check = self._checks.get(feature.slug)
56
+ if check is None:
57
+ check = self._default_check
58
+ if check is None:
59
+ return None
60
+ result = check(feature, node)
61
+ return self._normalize_result(feature, result)
62
+
63
+ def _normalize_result(
64
+ self, feature: "NodeFeature", result: Any
65
+ ) -> FeatureCheckResult:
66
+ if isinstance(result, FeatureCheckResult):
67
+ return result
68
+ if result is None:
69
+ return FeatureCheckResult(
70
+ True,
71
+ f"{feature.display} check completed successfully.",
72
+ messages.SUCCESS,
73
+ )
74
+ if isinstance(result, tuple) and len(result) >= 2:
75
+ success, message, *rest = result
76
+ level = rest[0] if rest else (
77
+ messages.SUCCESS if success else messages.ERROR
78
+ )
79
+ return FeatureCheckResult(bool(success), str(message), int(level))
80
+ if isinstance(result, bool):
81
+ message = (
82
+ f"{feature.display} check {'passed' if result else 'failed'}."
83
+ )
84
+ level = messages.SUCCESS if result else messages.ERROR
85
+ return FeatureCheckResult(result, message, level)
86
+ raise TypeError(
87
+ f"Unsupported feature check result type: {type(result)!r}"
88
+ )
89
+
90
+
91
+ feature_checks = FeatureCheckRegistry()
92
+
93
+
94
+ @feature_checks.register_default
95
+ def _default_feature_check(
96
+ feature: "NodeFeature", node: Optional["Node"]
97
+ ) -> FeatureCheckResult:
98
+ from .models import Node
99
+
100
+ target: Optional["Node"] = node or Node.get_local()
101
+ if target is None:
102
+ return FeatureCheckResult(
103
+ False,
104
+ f"No local node is registered; cannot verify {feature.display}.",
105
+ messages.WARNING,
106
+ )
107
+ try:
108
+ enabled = feature.is_enabled
109
+ except Exception as exc: # pragma: no cover - defensive
110
+ return FeatureCheckResult(
111
+ False,
112
+ f"{feature.display} check failed: {exc}",
113
+ messages.ERROR,
114
+ )
115
+ if enabled:
116
+ return FeatureCheckResult(
117
+ True,
118
+ f"{feature.display} is enabled on {target.hostname}.",
119
+ messages.SUCCESS,
120
+ )
121
+ return FeatureCheckResult(
122
+ False,
123
+ f"{feature.display} is not enabled on {target.hostname}.",
124
+ messages.WARNING,
125
+ )
126
+
127
+
128
+ __all__ = [
129
+ "FeatureCheck",
130
+ "FeatureCheckRegistry",
131
+ "FeatureCheckResult",
132
+ "feature_checks",
133
+ ]