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.
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/METADATA +2 -2
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/RECORD +38 -35
- config/settings.py +7 -2
- core/admin.py +246 -68
- core/apps.py +21 -0
- core/models.py +41 -8
- core/reference_utils.py +1 -1
- core/release.py +4 -0
- core/system.py +6 -3
- core/tasks.py +92 -40
- core/tests.py +64 -0
- core/views.py +131 -17
- nodes/admin.py +316 -6
- nodes/feature_checks.py +133 -0
- nodes/models.py +83 -26
- nodes/reports.py +411 -0
- nodes/tests.py +365 -36
- nodes/utils.py +32 -0
- ocpp/admin.py +278 -15
- ocpp/consumers.py +506 -8
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +234 -4
- ocpp/simulator.py +321 -22
- ocpp/store.py +110 -2
- ocpp/tests.py +789 -6
- ocpp/transactions_io.py +17 -3
- ocpp/views.py +225 -19
- pages/admin.py +135 -3
- pages/context_processors.py +15 -1
- pages/defaults.py +1 -2
- pages/forms.py +38 -0
- pages/models.py +136 -1
- pages/tests.py +262 -4
- pages/urls.py +1 -0
- pages/views.py +52 -3
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.11.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
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"
|
|
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 = (
|
|
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):
|
nodes/feature_checks.py
ADDED
|
@@ -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
|
+
]
|