netbox-pdu-control 0.2.0__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.
Files changed (53) hide show
  1. netbox_pdu_control/__init__.py +56 -0
  2. netbox_pdu_control/api/__init__.py +6 -0
  3. netbox_pdu_control/api/serializers.py +101 -0
  4. netbox_pdu_control/api/urls.py +12 -0
  5. netbox_pdu_control/api/views.py +23 -0
  6. netbox_pdu_control/backends/__init__.py +25 -0
  7. netbox_pdu_control/backends/base.py +139 -0
  8. netbox_pdu_control/backends/raritan.py +709 -0
  9. netbox_pdu_control/backends/unifi.py +351 -0
  10. netbox_pdu_control/choices.py +55 -0
  11. netbox_pdu_control/filtersets.py +58 -0
  12. netbox_pdu_control/forms.py +142 -0
  13. netbox_pdu_control/graphql/__init__.py +3 -0
  14. netbox_pdu_control/graphql/enums.py +6 -0
  15. netbox_pdu_control/graphql/filters.py +37 -0
  16. netbox_pdu_control/graphql/schema.py +14 -0
  17. netbox_pdu_control/graphql/types.py +28 -0
  18. netbox_pdu_control/jobs.py +370 -0
  19. netbox_pdu_control/migrations/0001_initial.py +179 -0
  20. netbox_pdu_control/migrations/0002_managedpdu_last_metrics_fetched.py +17 -0
  21. netbox_pdu_control/migrations/0003_pduinlet_poleline_l1_current_a_and_more.py +100 -0
  22. netbox_pdu_control/migrations/0004_pduoutlet_apparent_power_va.py +20 -0
  23. netbox_pdu_control/migrations/0005_managedpdu_metrics_status.py +24 -0
  24. netbox_pdu_control/migrations/0006_managedpdu_pdu_name_alter_managedpdu_verify_ssl.py +31 -0
  25. netbox_pdu_control/migrations/0007_managedpdu_sync_metrics_enabled.py +28 -0
  26. netbox_pdu_control/migrations/__init__.py +7 -0
  27. netbox_pdu_control/models.py +619 -0
  28. netbox_pdu_control/navigation.py +60 -0
  29. netbox_pdu_control/search.py +23 -0
  30. netbox_pdu_control/tables.py +254 -0
  31. netbox_pdu_control/template_content.py +29 -0
  32. netbox_pdu_control/templates/netbox_pdu_control/inc/device_pdu_button.html +3 -0
  33. netbox_pdu_control/templates/netbox_pdu_control/inc/device_pdu_outlets.html +11 -0
  34. netbox_pdu_control/templates/netbox_pdu_control/managedpdu.html +307 -0
  35. netbox_pdu_control/templates/netbox_pdu_control/pduinlet.html +228 -0
  36. netbox_pdu_control/templates/netbox_pdu_control/pduoutlet.html +166 -0
  37. netbox_pdu_control/testing/__init__.py +421 -0
  38. netbox_pdu_control/testing/utils.py +339 -0
  39. netbox_pdu_control/tests/__init__.py +6 -0
  40. netbox_pdu_control/tests/test_api.py +117 -0
  41. netbox_pdu_control/tests/test_backends_raritan.py +1121 -0
  42. netbox_pdu_control/tests/test_backends_unifi.py +793 -0
  43. netbox_pdu_control/tests/test_factory.py +96 -0
  44. netbox_pdu_control/tests/test_graphql.py +94 -0
  45. netbox_pdu_control/tests/test_models.py +184 -0
  46. netbox_pdu_control/tests/test_views.py +470 -0
  47. netbox_pdu_control/urls.py +84 -0
  48. netbox_pdu_control/views.py +518 -0
  49. netbox_pdu_control-0.2.0.dist-info/METADATA +524 -0
  50. netbox_pdu_control-0.2.0.dist-info/RECORD +53 -0
  51. netbox_pdu_control-0.2.0.dist-info/WHEEL +5 -0
  52. netbox_pdu_control-0.2.0.dist-info/licenses/LICENSE +200 -0
  53. netbox_pdu_control-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,56 @@
1
+ """
2
+ NetBox PDU Control
3
+
4
+ Plugin configuration for NetBox PDU Control.
5
+
6
+ For a complete list of PluginConfig attributes, see:
7
+ https://docs.netbox.dev/en/stable/plugins/development/#pluginconfig-attributes
8
+ """
9
+
10
+ __author__ = "tak-55"
11
+ __email__ = "8895617+tak-55@users.noreply.github.com"
12
+ __version__ = "0.2.0"
13
+
14
+
15
+ from netbox.plugins import PluginConfig
16
+
17
+
18
+ class PduConfig(PluginConfig):
19
+ name = "netbox_pdu_control"
20
+ verbose_name = "NetBox PDU Control"
21
+ description = "NetBox plugin for Managed PDUs (Raritan / Ubiquiti)."
22
+ author = "tak-55"
23
+ author_email = "8895617+tak-55@users.noreply.github.com"
24
+ version = __version__
25
+ base_url = "pdu"
26
+ min_version = "4.5.0"
27
+ max_version = "4.5.99"
28
+ graphql_schema = "graphql.schema"
29
+ queues = ["default"]
30
+
31
+ def ready(self):
32
+ super().ready()
33
+ from . import jobs # noqa: F401 — registers @system_job if metrics_poll_interval is set
34
+
35
+ self._cleanup_stuck_jobs()
36
+
37
+ def _cleanup_stuck_jobs(self):
38
+ """Mark any stuck 'running' PDU jobs as errored on startup (e.g. after container restart or laptop sleep)."""
39
+ try:
40
+ from core.choices import JobStatusChoices
41
+ from core.models import Job
42
+
43
+ stuck = Job.objects.filter(
44
+ name__in=["PDU Get Metrics", "PDU Sync"],
45
+ status=JobStatusChoices.STATUS_RUNNING,
46
+ )
47
+ count = stuck.update(status=JobStatusChoices.STATUS_ERRORED)
48
+ if count:
49
+ import logging
50
+
51
+ logging.getLogger(__name__).warning("Cleaned up %d stuck PDU job(s) on startup", count)
52
+ except Exception:
53
+ pass # Do not block startup if cleanup fails
54
+
55
+
56
+ config = PduConfig
@@ -0,0 +1,6 @@
1
+ """
2
+ REST API for NetBox PDU Plugin.
3
+
4
+ For more information on developing NetBox REST APIs, see:
5
+ https://docs.netbox.dev/en/stable/plugins/development/rest-api/
6
+ """
@@ -0,0 +1,101 @@
1
+ from dcim.api.serializers import DeviceSerializer
2
+ from netbox.api.serializers import NetBoxModelSerializer
3
+ from rest_framework import serializers
4
+
5
+ from ..models import ManagedPDU, PDUInlet, PDUOutlet
6
+
7
+
8
+ class ManagedPDUSerializer(NetBoxModelSerializer):
9
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:managedpdu-detail")
10
+ device = DeviceSerializer(nested=True)
11
+ outlet_count = serializers.IntegerField(read_only=True)
12
+ api_password = serializers.CharField(write_only=True)
13
+
14
+ class Meta:
15
+ model = ManagedPDU
16
+ fields = (
17
+ "id",
18
+ "url",
19
+ "display",
20
+ "device",
21
+ "vendor",
22
+ "api_url",
23
+ "api_username",
24
+ "api_password",
25
+ "verify_ssl",
26
+ "sync_enabled",
27
+ "metrics_enabled",
28
+ "sync_status",
29
+ "last_synced",
30
+ "outlet_count",
31
+ "comments",
32
+ "tags",
33
+ "custom_fields",
34
+ "created",
35
+ "last_updated",
36
+ )
37
+ brief_fields = ("id", "url", "display", "device")
38
+ # api_password is write-only — accepted on POST/PATCH but never returned in responses
39
+
40
+
41
+ class PDUOutletSerializer(NetBoxModelSerializer):
42
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:pduoutlet-detail")
43
+ managed_pdu = ManagedPDUSerializer(nested=True)
44
+ connected_device = DeviceSerializer(nested=True, required=False, allow_null=True)
45
+
46
+ class Meta:
47
+ model = PDUOutlet
48
+ fields = (
49
+ "id",
50
+ "url",
51
+ "display",
52
+ "managed_pdu",
53
+ "outlet_number",
54
+ "outlet_name",
55
+ "connected_device",
56
+ "status",
57
+ "current_a",
58
+ "power_w",
59
+ "voltage_v",
60
+ "power_factor",
61
+ "energy_wh",
62
+ "energy_reset_at",
63
+ "last_updated_from_pdu",
64
+ "comments",
65
+ "tags",
66
+ "custom_fields",
67
+ "created",
68
+ "last_updated",
69
+ )
70
+ brief_fields = ("id", "url", "display", "outlet_number", "outlet_name")
71
+
72
+
73
+ class PDUInletSerializer(NetBoxModelSerializer):
74
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_pdu_control-api:pduinlet-detail")
75
+ managed_pdu = ManagedPDUSerializer(nested=True)
76
+
77
+ class Meta:
78
+ model = PDUInlet
79
+ fields = (
80
+ "id",
81
+ "url",
82
+ "display",
83
+ "managed_pdu",
84
+ "inlet_number",
85
+ "inlet_name",
86
+ "current_a",
87
+ "power_w",
88
+ "apparent_power_va",
89
+ "voltage_v",
90
+ "power_factor",
91
+ "frequency_hz",
92
+ "energy_wh",
93
+ "energy_reset_at",
94
+ "last_updated_from_pdu",
95
+ "comments",
96
+ "tags",
97
+ "custom_fields",
98
+ "created",
99
+ "last_updated",
100
+ )
101
+ brief_fields = ("id", "url", "display", "inlet_number", "inlet_name")
@@ -0,0 +1,12 @@
1
+ from netbox.api.routers import NetBoxRouter
2
+
3
+ from . import views
4
+
5
+ app_name = "netbox_pdu_control"
6
+
7
+ router = NetBoxRouter()
8
+ router.register("managed-pdus", views.ManagedPDUViewSet)
9
+ router.register("outlets", views.PDUOutletViewSet)
10
+ router.register("inlets", views.PDUInletViewSet)
11
+
12
+ urlpatterns = router.urls
@@ -0,0 +1,23 @@
1
+ from django.db.models import Count
2
+ from netbox.api.viewsets import NetBoxModelViewSet
3
+
4
+ from .. import filtersets, models
5
+ from .serializers import ManagedPDUSerializer, PDUInletSerializer, PDUOutletSerializer
6
+
7
+
8
+ class ManagedPDUViewSet(NetBoxModelViewSet):
9
+ queryset = models.ManagedPDU.objects.annotate(outlet_count=Count("outlets")).prefetch_related("tags")
10
+ serializer_class = ManagedPDUSerializer
11
+ filterset_class = filtersets.ManagedPDUFilterSet
12
+
13
+
14
+ class PDUOutletViewSet(NetBoxModelViewSet):
15
+ queryset = models.PDUOutlet.objects.select_related("managed_pdu", "connected_device").prefetch_related("tags")
16
+ serializer_class = PDUOutletSerializer
17
+ filterset_class = filtersets.PDUOutletFilterSet
18
+
19
+
20
+ class PDUInletViewSet(NetBoxModelViewSet):
21
+ queryset = models.PDUInlet.objects.select_related("managed_pdu").prefetch_related("tags")
22
+ serializer_class = PDUInletSerializer
23
+ filterset_class = filtersets.PDUInletFilterSet
@@ -0,0 +1,25 @@
1
+ from .base import BasePDUClient, PDUClientError
2
+ from .raritan import RaritanPDUClient
3
+ from .unifi import UniFiPDUClient
4
+
5
+ _VENDOR_BACKENDS = {
6
+ "raritan": RaritanPDUClient,
7
+ "ubiquiti": UniFiPDUClient,
8
+ }
9
+
10
+
11
+ def get_pdu_client(managed_pdu) -> BasePDUClient:
12
+ """
13
+ Return the appropriate PDU client for the given ManagedPDU instance.
14
+ Raises PDUClientError if no backend is registered for the vendor.
15
+ """
16
+ backend_class = _VENDOR_BACKENDS.get(managed_pdu.vendor)
17
+ if not backend_class:
18
+ raise PDUClientError(f"No backend registered for vendor: {managed_pdu.vendor!r}")
19
+ return backend_class(
20
+ base_url=managed_pdu.api_url,
21
+ username=managed_pdu.api_username,
22
+ password=managed_pdu.api_password,
23
+ verify_ssl=managed_pdu.verify_ssl,
24
+ managed_pdu=managed_pdu,
25
+ )
@@ -0,0 +1,139 @@
1
+ """
2
+ Base interface for PDU vendor backends.
3
+
4
+ To add a new vendor:
5
+ 1. Create backends/<vendor>.py implementing BasePDUClient
6
+ 2. Register it in backends/__init__._VENDOR_BACKENDS
7
+ 3. Add the vendor to choices.VendorChoices
8
+ """
9
+
10
+ from abc import ABC, abstractmethod
11
+
12
+
13
+ class PDUClientError(Exception):
14
+ """Raised when communication with a PDU fails."""
15
+
16
+ pass
17
+
18
+
19
+ class BasePDUClient(ABC):
20
+ """
21
+ Abstract base class for PDU vendor backends.
22
+
23
+ All backends must implement these methods so that views and sync logic
24
+ can work with any vendor without modification.
25
+ """
26
+
27
+ def __init__(self, base_url: str, username: str, password: str, verify_ssl: bool = True, **kwargs):
28
+ self.base_url = base_url
29
+ self.username = username
30
+ self.password = password
31
+ self.verify_ssl = verify_ssl
32
+
33
+ #: Set to True in backends that implement get_all_metrics_prometheus().
34
+ supports_prometheus_metrics: bool = False
35
+
36
+ @abstractmethod
37
+ def get_pdu_info(self) -> dict:
38
+ """
39
+ Return basic PDU hardware information.
40
+
41
+ Must contain:
42
+ model (str) - PDU model name
43
+ serial_number (str) - Serial number
44
+ firmware_version (str) - Firmware version string
45
+ pdu_name (str) - Device name configured on the PDU hardware (may be empty)
46
+ network_interfaces (list[dict]) - each dict:
47
+ name (str) - interface name (e.g. 'ETH1')
48
+ mac_address (str) - MAC address
49
+ ip_address (str) - IP address (may be empty)
50
+ """
51
+
52
+ @abstractmethod
53
+ def get_all_outlet_data(self) -> list[dict]:
54
+ """
55
+ Return status and sensor data for all outlets.
56
+
57
+ Each dict must contain:
58
+ outlet_number (int) - 1-indexed
59
+ name (str) - outlet label (may be empty)
60
+ switchingState(str) - 'on', 'off', or 'unknown'
61
+ current_a (float|None)
62
+ power_w (float|None)
63
+ voltage_v (float|None)
64
+ power_factor (float|None)
65
+ energy_wh (float|None)
66
+ energy_reset_epoch (float|None)
67
+ """
68
+
69
+ @abstractmethod
70
+ def get_single_outlet_data(self, outlet_index: int) -> dict:
71
+ """Return data for one outlet (0-indexed). Same dict format as get_all_outlet_data."""
72
+
73
+ @abstractmethod
74
+ def get_all_inlet_data(self) -> list[dict]:
75
+ """
76
+ Return power data for all inlets.
77
+
78
+ Each dict must contain:
79
+ inlet_number (int)
80
+ name (str)
81
+ current_a (float|None)
82
+ power_w (float|None)
83
+ apparent_power_va (float|None)
84
+ voltage_v (float|None)
85
+ power_factor (float|None)
86
+ frequency_hz (float|None)
87
+ energy_wh (float|None)
88
+ energy_reset_epoch(float|None)
89
+ """
90
+
91
+ @abstractmethod
92
+ def get_single_inlet_data(self, inlet_index: int) -> dict:
93
+ """Return data for one inlet (0-indexed). Same dict format as get_all_inlet_data."""
94
+
95
+ @abstractmethod
96
+ def set_outlet_power_state(self, outlet_index: int, state: str) -> None:
97
+ """
98
+ Change the power state of an outlet.
99
+
100
+ Args:
101
+ outlet_index: 0-indexed outlet number
102
+ state: 'on', 'off', or 'cycle'
103
+ """
104
+
105
+ @abstractmethod
106
+ def get_outlet_power_state_by_index(self, outlet_index: int) -> str:
107
+ """Return the current power state of an outlet: 'on', 'off', or 'unknown'."""
108
+
109
+ @abstractmethod
110
+ def set_outlet_name(self, outlet_index: int, name: str) -> None:
111
+ """Push a display name to the PDU outlet (0-indexed)."""
112
+
113
+ @abstractmethod
114
+ def set_inlet_name(self, inlet_index: int, name: str) -> None:
115
+ """Push a display name to the PDU inlet (0-indexed)."""
116
+
117
+ def get_outlet_thresholds(self, outlet_index: int) -> list[dict]:
118
+ """
119
+ Return threshold data for one outlet (0-indexed).
120
+
121
+ Each dict must contain:
122
+ label (str) - sensor label (e.g. 'Current')
123
+ unit (str) - unit string (e.g. 'A')
124
+ lower_critical (float|None)
125
+ lower_warning (float|None)
126
+ upper_warning (float|None)
127
+ upper_critical (float|None)
128
+
129
+ Default implementation returns [] (vendor does not support thresholds).
130
+ Override in backends that support threshold retrieval.
131
+ """
132
+ return []
133
+
134
+ def get_inlet_thresholds(self, inlet_index: int) -> list[dict]:
135
+ """
136
+ Return threshold data for one inlet (0-indexed). Same dict format as get_outlet_thresholds.
137
+ Default implementation returns [].
138
+ """
139
+ return []