diff --git a/netbox/circuits/migrations/0015_custom_tag_models.py b/netbox/circuits/migrations/0015_custom_tag_models.py index d00eb36d7..11bde72ff 100644 --- a/netbox/circuits/migrations/0015_custom_tag_models.py +++ b/netbox/circuits/migrations/0015_custom_tag_models.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0014_circuittermination_description'), - ('extras', '0018_tag_taggeditem'), + ('extras', '0019_tag_taggeditem'), ] operations = [ diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index be1106308..5b818a945 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -43,7 +43,7 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - url(r'^circuit-terminations/(?P\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + url(r'^circuit-terminations/(?P\d+)/connect/(?P[\w-]+)/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), url(r'^circuit-terminations/(?P\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index e53259e94..16d77bd69 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -3,8 +3,8 @@ from rest_framework import serializers from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, - Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate, - Region, Site, VirtualChassis, + Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole, + RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from utilities.api import ChoiceField, WritableNestedSerializer @@ -21,7 +21,9 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedManufacturerSerializer', 'NestedPlatformSerializer', + 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', + 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedRackGroupSerializer', 'NestedRackRoleSerializer', @@ -247,3 +249,23 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): class Meta: model = VirtualChassis fields = ['id', 'url', 'master'] + + +# +# Power panels/feeds +# + +class NestedPowerPanelSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') + + class Meta: + model = PowerPanel + fields = ['id', 'url', 'name'] + + +class NestedPowerFeedSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') + + class Meta: + model = PowerFeed + fields = ['id', 'url', 'name'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f82aec0f..c31dc5c09 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -8,8 +8,9 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) from extras.api.customfields import CustomFieldModelSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -209,15 +210,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() + power_port = PowerPortTemplateSerializer( + required=False + ) + feed_leg = ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + allow_null=True + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg'] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -371,14 +380,26 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() - cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) + power_port = NestedPowerPortSerializer( + required=False + ) + feed_leg = ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + allow_null=True + ) + cable = NestedCableSerializer( + read_only=True + ) + tags = TagListSerializerField( + required=False + ) class Meta: model = PowerOutlet fields = [ - 'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', - 'cable', 'tags', + 'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', + 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -390,7 +411,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerPort fields = [ - 'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', + 'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -592,3 +613,56 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = VirtualChassis fields = ['id', 'master', 'domain', 'tags'] + + +# +# Power panels +# + + +class PowerPanelSerializer(ValidatedModelSerializer): + site = NestedSiteSerializer() + rack_group = NestedRackGroupSerializer( + required=False, + allow_null=True, + default=None + ) + + class Meta: + model = PowerPanel + fields = ['id', 'site', 'rack_group', 'name'] + + +class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): + power_panel = NestedPowerPanelSerializer() + rack = NestedRackSerializer( + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=POWERFEED_TYPE_CHOICES, + default=POWERFEED_TYPE_PRIMARY + ) + status = ChoiceField( + choices=POWERFEED_STATUS_CHOICES, + default=POWERFEED_STATUS_ACTIVE + ) + supply = ChoiceField( + choices=POWERFEED_SUPPLY_CHOICES, + default=POWERFEED_SUPPLY_AC + ) + phase = ChoiceField( + choices=POWERFEED_PHASE_CHOICES, + default=POWERFEED_PHASE_SINGLE + ) + tags = TagListSerializerField( + required=False + ) + + class Meta: + model = PowerFeed + fields = [ + 'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'power_factor', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 006a61bad..fd55d9b05 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet) # Virtual chassis router.register(r'virtual-chassis', views.VirtualChassisViewSet) +# Power +router.register(r'power-panels', views.PowerPanelViewSet) +router.register(r'power-feeds', views.PowerFeedViewSet) + # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 45df733b2..88d604505 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -16,8 +16,9 @@ from dcim import filters from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet @@ -43,6 +44,8 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): (FrontPortTemplate, ['type']), (Interface, ['form_factor', 'mode']), (InterfaceTemplate, ['form_factor']), + (PowerOutlet, ['feed_leg']), + (PowerOutletTemplate, ['feed_leg']), (PowerPort, ['connection_status']), (Rack, ['outer_unit', 'status', 'type', 'width']), (RearPort, ['type']), @@ -407,7 +410,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): class PowerPortViewSet(CableTraceMixin, ModelViewSet): queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' + 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable' ).prefetch_related( 'tags' ) @@ -497,7 +500,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): queryset = PowerPort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilter @@ -536,6 +539,26 @@ class VirtualChassisViewSet(ModelViewSet): serializer_class = serializers.VirtualChassisSerializer +# +# Power panels +# + +class PowerPanelViewSet(ModelViewSet): + queryset = PowerPanel.objects.select_related('site', 'rack_group') + serializer_class = serializers.PowerPanelSerializer + filterset_class = filters.PowerPanelFilter + + +# +# Power feeds +# + +class PowerFeedViewSet(CustomFieldModelViewSet): + queryset = PowerFeed.objects.select_related('power_panel', 'rack').prefetch_related('tags') + serializer_class = serializers.PowerFeedSerializer + filterset_class = filters.PowerFeedFilter + + # # Miscellaneous # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index af2547bc4..6c135254c 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -422,7 +422,7 @@ CABLE_TERMINATION_TYPE_CHOICES = { COMPATIBLE_TERMINATION_TYPES = { 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet'], + 'powerport': ['poweroutlet', 'powerfeed'], 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], @@ -445,3 +445,41 @@ RACK_DIMENSION_UNIT_CHOICES = ( (LENGTH_UNIT_MILLIMETER, 'Millimeters'), (LENGTH_UNIT_INCH, 'Inches'), ) + +# Power feeds +POWERFEED_TYPE_PRIMARY = 1 +POWERFEED_TYPE_REDUNDANT = 2 +POWERFEED_TYPE_CHOICES = ( + (POWERFEED_TYPE_PRIMARY, 'Primary'), + (POWERFEED_TYPE_REDUNDANT, 'Redundant'), +) +POWERFEED_SUPPLY_AC = 1 +POWERFEED_SUPPLY_DC = 2 +POWERFEED_SUPPLY_CHOICES = ( + (POWERFEED_SUPPLY_AC, 'AC'), + (POWERFEED_SUPPLY_DC, 'DC'), +) +POWERFEED_PHASE_SINGLE = 1 +POWERFEED_PHASE_3PHASE = 3 +POWERFEED_PHASE_CHOICES = ( + (POWERFEED_PHASE_SINGLE, 'Single phase'), + (POWERFEED_PHASE_3PHASE, 'Three-phase'), +) +POWERFEED_STATUS_OFFLINE = 0 +POWERFEED_STATUS_ACTIVE = 1 +POWERFEED_STATUS_PLANNED = 2 +POWERFEED_STATUS_FAILED = 4 +POWERFEED_STATUS_CHOICES = ( + (POWERFEED_STATUS_ACTIVE, 'Active'), + (POWERFEED_STATUS_OFFLINE, 'Offline'), + (POWERFEED_STATUS_PLANNED, 'Planned'), + (POWERFEED_STATUS_FAILED, 'Failed'), +) +POWERFEED_LEG_A = 1 +POWERFEED_LEG_B = 2 +POWERFEED_LEG_C = 3 +POWERFEED_LEG_CHOICES = ( + (POWERFEED_LEG_A, 'A'), + (POWERFEED_LEG_B, 'B'), + (POWERFEED_LEG_C, 'C'), +) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 2e303e325..ea535f4f2 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -15,8 +15,9 @@ from .constants import * from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) @@ -37,7 +38,7 @@ class RegionFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SiteFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -122,7 +123,7 @@ class RackRoleFilter(NameSlugSearchFilterSet): fields = ['name', 'slug', 'color'] -class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): +class RackFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -1065,3 +1066,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet): Q(device__name__icontains=value) | Q(_connected_interface__device__name__icontains=value) ) + + +class PowerPanelFilter(django_filters.FilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + rack_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack_group', + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + ) + + class Meta: + model = PowerPanel + fields = ['name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) + ) + return queryset.filter(qs_filter) + + +class PowerFeedFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='power_panel__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='power_panel__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + power_panel_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPanel.objects.all(), + label='Power panel (ID)', + ) + rack_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + tag = TagFilter() + + class Meta: + model = PowerFeed + fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'power_factor'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 215fbb702..cdcc860e8 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -2667,7 +2667,7 @@ "fields": { "device": 1, "name": "PEM0", - "connected_endpoint": 25, + "_connected_poweroutlet": 25, "connection_status": true } }, @@ -2677,7 +2677,7 @@ "fields": { "device": 1, "name": "PEM1", - "connected_endpoint": 49, + "_connected_poweroutlet": 49, "connection_status": true } }, @@ -2687,7 +2687,7 @@ "fields": { "device": 1, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2697,7 +2697,7 @@ "fields": { "device": 1, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2707,7 +2707,7 @@ "fields": { "device": 2, "name": "PEM0", - "connected_endpoint": 26, + "_connected_poweroutlet": 26, "connection_status": true } }, @@ -2717,7 +2717,7 @@ "fields": { "device": 2, "name": "PEM1", - "connected_endpoint": 50, + "_connected_poweroutlet": 50, "connection_status": true } }, @@ -2727,7 +2727,7 @@ "fields": { "device": 2, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2737,7 +2737,7 @@ "fields": { "device": 2, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2747,7 +2747,7 @@ "fields": { "device": 4, "name": "PSU0", - "connected_endpoint": 28, + "_connected_poweroutlet": 28, "connection_status": true } }, @@ -2757,7 +2757,7 @@ "fields": { "device": 4, "name": "PSU1", - "connected_endpoint": 52, + "_connected_poweroutlet": 52, "connection_status": true } }, @@ -2767,7 +2767,7 @@ "fields": { "device": 5, "name": "PSU0", - "connected_endpoint": 56, + "_connected_poweroutlet": 56, "connection_status": true } }, @@ -2777,7 +2777,7 @@ "fields": { "device": 5, "name": "PSU1", - "connected_endpoint": 32, + "_connected_poweroutlet": 32, "connection_status": true } }, @@ -2787,7 +2787,7 @@ "fields": { "device": 3, "name": "PSU0", - "connected_endpoint": 27, + "_connected_poweroutlet": 27, "connection_status": true } }, @@ -2797,7 +2797,7 @@ "fields": { "device": 3, "name": "PSU1", - "connected_endpoint": 51, + "_connected_poweroutlet": 51, "connection_status": true } }, @@ -2807,7 +2807,7 @@ "fields": { "device": 7, "name": "PEM0", - "connected_endpoint": 53, + "_connected_poweroutlet": 53, "connection_status": true } }, @@ -2817,7 +2817,7 @@ "fields": { "device": 7, "name": "PEM1", - "connected_endpoint": 29, + "_connected_poweroutlet": 29, "connection_status": true } }, @@ -2827,7 +2827,7 @@ "fields": { "device": 7, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2837,7 +2837,7 @@ "fields": { "device": 7, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2847,7 +2847,7 @@ "fields": { "device": 8, "name": "PEM0", - "connected_endpoint": 54, + "_connected_poweroutlet": 54, "connection_status": true } }, @@ -2857,7 +2857,7 @@ "fields": { "device": 8, "name": "PEM1", - "connected_endpoint": 30, + "_connected_poweroutlet": 30, "connection_status": true } }, @@ -2867,7 +2867,7 @@ "fields": { "device": 8, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2877,7 +2877,7 @@ "fields": { "device": 8, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2887,7 +2887,7 @@ "fields": { "device": 6, "name": "PSU0", - "connected_endpoint": 55, + "_connected_poweroutlet": 55, "connection_status": true } }, @@ -2897,7 +2897,7 @@ "fields": { "device": 6, "name": "PSU1", - "connected_endpoint": 31, + "_connected_poweroutlet": 31, "connection_status": true } }, @@ -2907,7 +2907,7 @@ "fields": { "device": 9, "name": "PSU", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5a43d68e0..ba639a646 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -10,24 +10,24 @@ from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField from timezone_field import TimeZoneFormField +from circuits.models import Circuit, Provider from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, - ComponentForm, ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, - StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, + SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) from virtualization.models import Cluster, ClusterGroup from .constants import * from .models import ( Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis + InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, + Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) DEVICE_BY_PK_RE = r'{\d+\}' @@ -963,7 +963,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'maximum_draw', 'allocated_draw', ] widgets = { 'device_type': forms.HiddenInput(), @@ -977,16 +977,29 @@ class PowerPortTemplateCreateForm(ComponentForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False + ) class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'power_port', 'feed_leg', ] widgets = { 'device_type': forms.HiddenInput(), } + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Limit power_port choices to current DeviceType + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=self.parent + ) + class PowerOutletTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( @@ -1947,7 +1960,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'description', 'tags', + 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1972,6 +1985,10 @@ class PowerPortCreateForm(ComponentForm): # class PowerOutletForm(BootstrapMixin, forms.ModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) tags = TagField( required=False ) @@ -1979,12 +1996,20 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'description', 'tags', + 'device', 'name', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port choices to the local device + self.fields['power_port'].queryset = PowerPort.objects.filter( + device=self.instance.device + ) + class PowerOutletCreateForm(ComponentForm): name_pattern = ExpandableNameField( @@ -2004,6 +2029,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput() ) + feed_leg = forms.ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + ) description = forms.CharField( max_length=100, required=False @@ -2520,7 +2549,10 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + """ + Base form for connecting a Cable to a Device component + """ termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', @@ -2566,39 +2598,196 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): } ) ) - termination_b_type = forms.ModelChoiceField( - queryset=ContentType.objects.all(), - label='Type', - widget=ContentTypeSelect() - ) + + class Meta: + model = Cable + fields = [ + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', + 'label', 'color', 'length', 'length_unit', + ] + + +class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): termination_b_id = forms.IntegerField( label='Name', widget=APISelect( - api_url='/api/dcim/{{termination_b_type}}s/', + api_url='/api/dcim/console-ports/', disabled_indicator='cable', - conditional_query_params={ - 'termination_b_type__interface': 'type=physical', + ) + ) + + +class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/console-server-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-outlets/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/interfaces/', + disabled_indicator='cable', + additional_query_params={ + 'type': 'physical', } ) ) + +class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/front-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToRearPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/rear-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + widget=APISelect( + api_url='/api/circuits/providers/', + filter_for={ + 'termination_b_circuit': 'provider_id', + } + ) + ) + termination_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=APISelect( + api_url='/api/dcim/sites/', + filter_for={ + 'termination_b_circuit': 'site_id', + } + ) + ) + termination_b_circuit = ChainedModelChoiceField( + queryset=Circuit.objects.all(), + chains=( + ('provider', 'termination_b_provider'), + ), + label='Circuit', + widget=APISelect( + api_url='/api/circuits/circuits/', + display_field='cid', + filter_for={ + 'termination_b_id': 'circuit_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Side', + widget=APISelect( + api_url='/api/circuits/circuit-terminations/', + disabled_indicator='cable', + display_field='term_side' + ) + ) + class Meta: model = Cable fields = [ - 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_type', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', + 'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', + 'status', 'label', 'color', 'length', 'length_unit', ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Define available types for endpoint B based on the type of endpoint A - termination_a_type = self.instance.termination_a._meta.model_name - self.fields['termination_b_type'].queryset = ContentType.objects.filter( - model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type) - ).exclude( - model='circuittermination' +class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + widget=APISelect( + api_url='/api/dcim/sites/', + display_field='cid', + filter_for={ + 'termination_b_rackgroup': 'site_id', + 'termination_b_powerpanel': 'site_id', + } ) + ) + termination_b_rackgroup = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + label='Rack Group', + chains=( + ('site', 'termination_b_site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/', + display_field='cid', + filter_for={ + 'termination_b_powerpanel': 'rackgroup_id', + } + ) + ) + termination_b_powerpanel = ChainedModelChoiceField( + queryset=PowerPanel.objects.all(), + chains=( + ('site', 'termination_b_site'), + ('rack_group', 'termination_b_rackgroup'), + ), + label='Power Panel', + widget=APISelect( + api_url='/api/dcim/power-panels/', + filter_for={ + 'termination_b_id': 'power_panel_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-feeds/', + ) + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] class CableForm(BootstrapMixin, forms.ModelForm): @@ -3155,3 +3344,346 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --', ) + + +# +# Power panels +# + +class PowerPanelForm(BootstrapMixin, forms.ModelForm): + rack_group = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/', + ) + ) + + class Meta: + model = PowerPanel + fields = [ + 'site', 'rack_group', 'name', + ] + widgets = { + 'site': APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ), + } + + +class PowerPanelCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + rack_group_name = forms.CharField( + required=False, + help_text="Rack group name (optional)" + ) + + class Meta: + model = PowerPanel + fields = PowerPanel.csv_headers + + def clean(self): + + super().clean() + + site = self.cleaned_data.get('site') + rack_group_name = self.cleaned_data.get('rack_group_name') + + # Validate rack group + if rack_group_name: + try: + self.instance.rack_group = RackGroup.objects.get(site=site, name=rack_group_name) + except RackGroup.DoesNotExist: + raise forms.ValidationError( + "Rack group {} not found in site {}".format(rack_group_name, site) + ) + + +class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = PowerPanel + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'rack_id': 'site', + } + ) + ) + rack_group_id = FilterChoiceField( + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) + ) + + +# +# Power feeds +# + +class PowerFeedForm(BootstrapMixin, CustomFieldForm): + site = ChainedModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/sites/', + filter_for={ + 'power_panel': 'site_id', + 'rack': 'site_id', + } + ) + ) + comments = CommentField() + tags = TagField( + required=False + ) + + class Meta: + model = PowerFeed + fields = [ + 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'power_factor', 'comments', 'tags', + ] + widgets = { + 'power_panel': APISelect( + api_url="/api/dcim/power-panels/" + ), + 'rack': APISelect( + api_url="/api/dcim/racks/" + ), + 'status': StaticSelect2(), + 'type': StaticSelect2(), + 'supply': StaticSelect2(), + 'phase': StaticSelect2(), + } + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Initialize site field + if self.instance and self.instance.power_panel: + self.initial['site'] = self.instance.power_panel.site + + +class PowerFeedCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + panel_name = forms.ModelChoiceField( + queryset=PowerPanel.objects.all(), + to_field_name='name', + help_text='Name of upstream power panel', + error_messages={ + 'invalid_choice': 'Power panel not found.', + } + ) + rack_group = forms.CharField( + required=False, + help_text="Rack group name (optional)" + ) + rack_name = forms.CharField( + required=False, + help_text="Rack name (optional)" + ) + status = CSVChoiceField( + choices=POWERFEED_STATUS_CHOICES, + required=False, + help_text='Operational status' + ) + type = CSVChoiceField( + choices=POWERFEED_TYPE_CHOICES, + required=False, + help_text='Primary or redundant' + ) + supply = CSVChoiceField( + choices=POWERFEED_SUPPLY_CHOICES, + required=False, + help_text='AC/DC' + ) + phase = CSVChoiceField( + choices=POWERFEED_PHASE_CHOICES, + required=False, + help_text='Single or three-phase' + ) + + class Meta: + model = PowerFeed + fields = PowerFeed.csv_headers + + def clean(self): + + super().clean() + + site = self.cleaned_data.get('site') + panel_name = self.cleaned_data.get('panel_name') + rack_group = self.cleaned_data.get('rack_group') + rack_name = self.cleaned_data.get('rack_name') + + # Validate power panel + if panel_name: + try: + self.instance.power_panel = PowerPanel.objects.get(site=site, name=panel_name) + except Rack.DoesNotExist: + raise forms.ValidationError( + "Power panel {} not found in site {}".format(panel_name, site) + ) + + # Validate rack + if rack_name: + try: + self.instance.rack = Rack.objects.get(site=site, rack_group=rack_group, name=rack_name) + except Rack.DoesNotExist: + raise forms.ValidationError( + "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group) + ) + + +class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + powerpanel = forms.ModelChoiceField( + queryset=PowerPanel.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites", + filter_for={ + 'rackgroup': 'site_id', + } + ) + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks", + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + type = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_TYPE_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_PHASE_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + power_factor = forms.IntegerField( + required=False + ) + comments = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = [ + 'rackgroup', 'comments', + ] + + +class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = PowerFeed + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'rack_id': 'site', + } + ) + ) + rack_id = FilterChoiceField( + queryset=Rack.objects.all(), + label='Rack', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/racks/", + null_option=True, + ) + ) + status = forms.MultipleChoiceField( + choices=POWERFEED_STATUS_CHOICES, + required=False, + widget=StaticSelect2Multiple() + ) + type = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_TYPE_CHOICES), + required=False, + widget=StaticSelect2() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES), + required=False, + widget=StaticSelect2() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_PHASE_CHOICES), + required=False, + widget=StaticSelect2() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + power_factor = forms.IntegerField( + required=False + ) diff --git a/netbox/dcim/migrations/0070_custom_tag_models.py b/netbox/dcim/migrations/0070_custom_tag_models.py index e3b54b9cc..ee78bed02 100644 --- a/netbox/dcim/migrations/0070_custom_tag_models.py +++ b/netbox/dcim/migrations/0070_custom_tag_models.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('dcim', '0069_deprecate_nullablecharfield'), - ('extras', '0018_tag_taggeditem'), + ('extras', '0019_tag_taggeditem'), ] operations = [ diff --git a/netbox/dcim/migrations/0072_powerfeeds.py b/netbox/dcim/migrations/0072_powerfeeds.py new file mode 100644 index 000000000..9bd24e041 --- /dev/null +++ b/netbox/dcim/migrations/0072_powerfeeds.py @@ -0,0 +1,133 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0021_add_color_comments_changelog_to_tag'), + ('dcim', '0071_device_components_add_description'), + ] + + operations = [ + migrations.CreateModel( + name='PowerFeed', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=50)), + ('status', models.PositiveSmallIntegerField(default=1)), + ('type', models.PositiveSmallIntegerField(default=1)), + ('supply', models.PositiveSmallIntegerField(default=1)), + ('phase', models.PositiveSmallIntegerField(default=1)), + ('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])), + ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), + ('power_factor', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), + ('comments', models.TextField(blank=True)), + ('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')), + ], + options={ + 'ordering': ['power_panel', 'name'], + }, + ), + migrations.CreateModel( + name='PowerPanel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=50)), + ('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.AddField( + model_name='powerfeed', + name='power_panel', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel'), + ), + migrations.AddField( + model_name='powerfeed', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack'), + ), + migrations.AddField( + model_name='powerfeed', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='powerfeed', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'), + ), + migrations.AddField( + model_name='powerfeed', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.RenameField( + model_name='powerport', + old_name='connected_endpoint', + new_name='_connected_poweroutlet', + ), + migrations.AddField( + model_name='powerport', + name='_connected_powerfeed', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'), + ), + migrations.AddField( + model_name='powerport', + name='allocated_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerport', + name='maximum_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerporttemplate', + name='allocated_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerporttemplate', + name='maximum_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterUniqueTogether( + name='powerpanel', + unique_together={('site', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerfeed', + unique_together={('power_panel', 'name')}, + ), + migrations.AddField( + model_name='poweroutlet', + name='feed_leg', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='power_port', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='feed_leg', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='power_port', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cbfce0b91..020d30618 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Count, Q +from django.db.models import Count, Q, Sum from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager @@ -1053,6 +1053,18 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + maximum_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Maximum current draw (watts)" + ) + allocated_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Allocated current draw (watts)" + ) objects = DeviceComponentManager() @@ -1076,6 +1088,19 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + power_port = models.ForeignKey( + to='dcim.PowerPortTemplate', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='poweroutlet_templates' + ) + feed_leg = models.PositiveSmallIntegerField( + choices=POWERFEED_LEG_CHOICES, + blank=True, + null=True, + help_text="Phase (for three-phase feeds)" + ) objects = DeviceComponentManager() @@ -1086,6 +1111,14 @@ class PowerOutletTemplate(ComponentTemplateModel): def __str__(self): return self.name + def clean(self): + + # Validate power port assignment + if self.power_port and self.power_port.device_type != self.device_type: + raise ValidationError( + "Parent power port ({}) must belong to the same device type".format(self.power_port) + ) + class InterfaceTemplate(ComponentTemplateModel): """ @@ -1828,13 +1861,32 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) - connected_endpoint = models.OneToOneField( + maximum_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Maximum current draw (watts)" + ) + allocated_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Allocated current draw (watts)" + ) + _connected_poweroutlet = models.OneToOneField( to='dcim.PowerOutlet', on_delete=models.SET_NULL, related_name='connected_endpoint', blank=True, null=True ) + _connected_powerfeed = models.OneToOneField( + to='dcim.PowerFeed', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True @@ -1843,7 +1895,7 @@ class PowerPort(CableTermination, ComponentModel): objects = DeviceComponentManager() tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'description'] + csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description'] class Meta: ordering = ['device', 'name'] @@ -1859,9 +1911,68 @@ class PowerPort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.maximum_draw, + self.allocated_draw, self.description, ) + @property + def connected_endpoint(self): + if self._connected_poweroutlet: + return self._connected_poweroutlet + return self._connected_powerfeed + + @connected_endpoint.setter + def connected_endpoint(self, value): + if value is None: + self._connected_poweroutlet = None + self._connected_powerfeed = None + elif isinstance(value, PowerOutlet): + self._connected_poweroutlet = value + self._connected_powerfeed = None + elif isinstance(value, PowerFeed): + self._connected_poweroutlet = None + self._connected_powerfeed = value + else: + raise ValueError( + "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value)) + ) + + def get_power_stats(self): + """ + Return power utilization statistics + """ + feed = self._connected_powerfeed + if not feed or not self.poweroutlets.count(): + return None + + stats = [] + powerfeed_available = self._connected_powerfeed.available_power + + outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) + utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + maximum_draw=Sum('maximum_draw'), + allocated_draw=Sum('allocated_draw'), + ) + utilization['outlets'] = len(outlet_ids) + utilization['available_power'] = powerfeed_available + stats.append(utilization) + + # Per-leg stats for three-phase feeds + if feed.phase == POWERFEED_PHASE_3PHASE: + for leg, leg_name in POWERFEED_LEG_CHOICES: + outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) + utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + maximum_draw=Sum('maximum_draw'), + allocated_draw=Sum('allocated_draw'), + ) + utilization['name'] = 'Leg {}'.format(leg_name) + utilization['outlets'] = len(outlet_ids) + utilization['available_power'] = powerfeed_available / 3 + stats.append(utilization) + + return stats + # # Power outlets @@ -1879,6 +1990,19 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + power_port = models.ForeignKey( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='poweroutlets' + ) + feed_leg = models.PositiveSmallIntegerField( + choices=POWERFEED_LEG_CHOICES, + blank=True, + null=True, + help_text="Phase (for three-phase feeds)" + ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True @@ -1887,7 +2011,7 @@ class PowerOutlet(CableTermination, ComponentModel): objects = DeviceComponentManager() tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'description'] + csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description'] class Meta: unique_together = ['device', 'name'] @@ -1902,9 +2026,19 @@ class PowerOutlet(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.power_port.name if self.power_port else None, + self.get_feed_leg_display(), self.description, ) + def clean(self): + + # Validate power port assignment + if self.power_port and self.power_port.device != self.device: + raise ValidationError( + "Parent power port ({}) must belong to the same device".format(self.power_port) + ) + # # Interfaces @@ -2646,6 +2780,14 @@ class Cable(ChangeLoggedModel): def get_status_class(self): return 'success' if self.status else 'info' + def get_compatible_types(self): + """ + Return all termination types compatible with termination A. + """ + if self.termination_a is None: + return + return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + def get_path_endpoints(self): """ Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be @@ -2668,3 +2810,174 @@ class Cable(ChangeLoggedModel): b_endpoint = b_path[-1][2] return a_endpoint, b_endpoint, path_status + + +# +# Power +# + +class PowerPanel(ChangeLoggedModel): + """ + A distribution point for electrical power; e.g. a data center RPP. + """ + site = models.ForeignKey( + to='Site', + on_delete=models.PROTECT + ) + rack_group = models.ForeignKey( + to='RackGroup', + on_delete=models.PROTECT, + blank=True, + null=True + ) + name = models.CharField( + max_length=50 + ) + + csv_headers = ['site', 'rack_group_name', 'name'] + + class Meta: + ordering = ['site', 'name'] + unique_together = ['site', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerpanel', args=[self.pk]) + + def to_csv(self): + return ( + self.site.name, + self.rack_group.name if self.rack_group else None, + self.name, + ) + + def clean(self): + + # RackGroup must belong to assigned Site + if self.rack_group and self.rack_group.site != self.site: + raise ValidationError("Rack group {} ({}) is in a different site than {}".format( + self.rack_group, self.rack_group.site, self.site + )) + + +class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): + """ + An electrical circuit delivered from a PowerPanel. + """ + power_panel = models.ForeignKey( + to='PowerPanel', + on_delete=models.PROTECT, + related_name='powerfeeds' + ) + rack = models.ForeignKey( + to='Rack', + on_delete=models.PROTECT, + blank=True, + null=True + ) + connected_endpoint = models.OneToOneField( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) + name = models.CharField( + max_length=50 + ) + status = models.PositiveSmallIntegerField( + choices=POWERFEED_STATUS_CHOICES, + default=POWERFEED_STATUS_ACTIVE + ) + type = models.PositiveSmallIntegerField( + choices=POWERFEED_TYPE_CHOICES, + default=POWERFEED_TYPE_PRIMARY + ) + supply = models.PositiveSmallIntegerField( + choices=POWERFEED_SUPPLY_CHOICES, + default=POWERFEED_SUPPLY_AC + ) + phase = models.PositiveSmallIntegerField( + choices=POWERFEED_PHASE_CHOICES, + default=POWERFEED_PHASE_SINGLE + ) + voltage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=120 + ) + amperage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=20 + ) + power_factor = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], + default=80, + help_text="Maximum permissible draw (percentage)" + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager(through=TaggedItem) + + csv_headers = [ + 'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'power_factor', 'comments', + ] + + class Meta: + ordering = ['power_panel', 'name'] + unique_together = ['power_panel', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerfeed', args=[self.pk]) + + def to_csv(self): + return ( + self.power_panel.name, + self.rack.name if self.rack else None, + self.name, + self.get_status_display(), + self.get_type_display(), + self.get_supply_display(), + self.get_phase_display(), + self.voltage, + self.amperage, + self.power_factor, + self.comments, + ) + + def clean(self): + + # Rack must belong to same Site as PowerPanel + if self.rack and self.rack.site != self.power_panel.site: + raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format( + self.rack, self.rack.site, self.power_panel, self.power_panel.site + )) + + def get_type_class(self): + return STATUS_CLASSES[self.type] + + def get_status_class(self): + return STATUS_CLASSES[self.status] + + @property + def available_power(self): + kva = self.voltage * self.amperage * self.power_factor + if self.phase == POWERFEED_PHASE_3PHASE: + return kva * 1.732 + return kva diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index dd1f4f5f1..785166bb4 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) REGION_LINK = """ @@ -144,6 +145,10 @@ STATUS_LABEL = """ {{ record.get_status_display }} """ +TYPE_LABEL = """ +{{ record.get_type_display }} +""" + DEVICE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
{% endif %} @@ -786,3 +791,50 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis fields = ('pk', 'master', 'domain', 'member_count', 'actions') + + +# +# Power panels +# + +class PowerPanelTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site.slug')] + ) + powerfeed_count = tables.Column( + verbose_name='Feeds' + ) + + class Meta(BaseTable.Meta): + model = PowerPanel + fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + + +# +# Power feeds +# + +class PowerFeedTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + power_panel = tables.LinkColumn( + viewname='dcim:powerpanel', + args=[Accessor('power_panel.pk')], + ) + rack = tables.LinkColumn( + viewname='dcim:rack', + args=[Accessor('rack.pk')] + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + type = tables.TemplateColumn( + template_code=TYPE_LABEL + ) + + class Meta(BaseTable.Meta): + model = PowerFeed + fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3f9021bc6..f572047b5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,8 +7,8 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, + InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, + Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) from ipam.models import IPAddress, VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -3532,3 +3532,260 @@ class VirtualChassisTest(APITestCase): self.assertTrue( Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None) ) + + +class PowerPanelTest(APITestCase): + + def setUp(self): + + super().setUp() + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3') + self.powerpanel1 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1' + ) + self.powerpanel2 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2' + ) + self.powerpanel3 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3' + ) + + def test_get_powerpanel(self): + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerpanel1.name) + + def test_list_powerpanels(self): + + url = reverse('dcim-api:powerpanel-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_list_powerpanels_brief(self): + + url = reverse('dcim-api:powerpanel-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url'] + ) + + def test_create_powerpanel(self): + + data = { + 'name': 'Test Power Panel 4', + 'site': self.site1.pk, + 'rack_group': self.rackgroup1.pk, + } + + url = reverse('dcim-api:powerpanel-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPanel.objects.count(), 4) + powerpanel4 = PowerPanel.objects.get(pk=response.data['id']) + self.assertEqual(powerpanel4.name, data['name']) + self.assertEqual(powerpanel4.site_id, data['site']) + self.assertEqual(powerpanel4.rack_group_id, data['rack_group']) + + def test_create_powerpanel_bulk(self): + + data = [ + { + 'name': 'Test Power Panel 4', + 'site': self.site1.pk, + 'rack_group': self.rackgroup1.pk, + }, + { + 'name': 'Test Power Panel 5', + 'site': self.site1.pk, + 'rack_group': self.rackgroup2.pk, + }, + { + 'name': 'Test Power Panel 6', + 'site': self.site1.pk, + 'rack_group': self.rackgroup3.pk, + }, + ] + + url = reverse('dcim-api:powerpanel-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPanel.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_powerpanel(self): + + data = { + 'name': 'Test Power Panel X', + 'rack_group': self.rackgroup2.pk, + } + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerPanel.objects.count(), 3) + powerpanel1 = PowerPanel.objects.get(pk=response.data['id']) + self.assertEqual(powerpanel1.name, data['name']) + self.assertEqual(powerpanel1.rack_group_id, data['rack_group']) + + def test_delete_powerpanel(self): + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPanel.objects.count(), 2) + + +class PowerFeedTest(APITestCase): + + def setUp(self): + + super().setUp() + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rack1 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42, + ) + self.rack2 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42, + ) + self.rack3 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42, + ) + self.rack4 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42, + ) + self.powerpanel1 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1' + ) + self.powerpanel2 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2' + ) + self.powerfeed1 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed2 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT + ) + self.powerfeed3 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed4 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT + ) + self.powerfeed5 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed6 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT + ) + + def test_get_powerfeed(self): + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerfeed1.name) + + def test_list_powerfeeds(self): + + url = reverse('dcim-api:powerfeed-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 6) + + def test_list_powerfeeds_brief(self): + + url = reverse('dcim-api:powerfeed-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url'] + ) + + def test_create_powerfeed(self): + + data = { + 'name': 'Test Power Feed 4A', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_PRIMARY, + } + + url = reverse('dcim-api:powerfeed-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerFeed.objects.count(), 7) + powerfeed4 = PowerFeed.objects.get(pk=response.data['id']) + self.assertEqual(powerfeed4.name, data['name']) + self.assertEqual(powerfeed4.power_panel_id, data['power_panel']) + self.assertEqual(powerfeed4.rack_id, data['rack']) + + def test_create_powerfeed_bulk(self): + + data = [ + { + 'name': 'Test Power Feed 4A', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_PRIMARY, + }, + { + 'name': 'Test Power Feed 4B', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_REDUNDANT, + }, + ] + + url = reverse('dcim-api:powerfeed-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerFeed.objects.count(), 8) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + + def test_update_powerfeed(self): + + data = { + 'name': 'Test Power Feed X', + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_REDUNDANT, + } + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerFeed.objects.count(), 6) + powerfeed1 = PowerFeed.objects.get(pk=response.data['id']) + self.assertEqual(powerfeed1.name, data['name']) + self.assertEqual(powerfeed1.rack_id, data['rack']) + self.assertEqual(powerfeed1.type, data['type']) + + def test_delete_powerfeed(self): + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerFeed.objects.count(), 5) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 21d620af1..82e32dad2 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -6,7 +6,8 @@ from secrets.views import secret_add from . import views from .models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, - PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, + PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, + VirtualChassis, ) app_name = 'dcim' @@ -161,7 +162,7 @@ urlpatterns = [ url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + url(r'^console-ports/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), url(r'^console-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), @@ -170,7 +171,7 @@ urlpatterns = [ url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + url(r'^console-server-ports/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), url(r'^console-server-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), @@ -181,7 +182,7 @@ urlpatterns = [ url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + url(r'^power-ports/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), url(r'^power-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), @@ -190,7 +191,7 @@ urlpatterns = [ url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + url(r'^power-outlets/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), url(r'^power-outlets/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), @@ -202,7 +203,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^interfaces/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + url(r'^interfaces/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), url(r'^interfaces/(?P\d+)/$', views.InterfaceView.as_view(), name='interface'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), @@ -217,7 +218,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'), url(r'^devices/(?P\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), url(r'^devices/(?P\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - url(r'^front-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + url(r'^front-ports/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), url(r'^front-ports/(?P\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'), url(r'^front-ports/(?P\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'), url(r'^front-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), @@ -229,7 +230,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'), url(r'^devices/(?P\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), url(r'^devices/(?P\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - url(r'^rear-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + url(r'^rear-ports/(?P\d+)/connect/(?P[\w-]+)/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), url(r'^rear-ports/(?P\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'), url(r'^rear-ports/(?P\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'), url(r'^rear-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), @@ -279,4 +280,25 @@ urlpatterns = [ url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + # Power panels + url(r'^power-panels/$', views.PowerPanelListView.as_view(), name='powerpanel_list'), + url(r'^power-panels/add/$', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + url(r'^power-panels/import/$', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), + url(r'^power-panels/delete/$', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), + url(r'^power-panels/(?P\d+)/$', views.PowerPanelView.as_view(), name='powerpanel'), + url(r'^power-panels/(?P\d+)/edit/$', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), + url(r'^power-panels/(?P\d+)/delete/$', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), + url(r'^power-panels/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + + # Racks + url(r'^power-feeds/$', views.PowerFeedListView.as_view(), name='powerfeed_list'), + url(r'^power-feeds/add/$', views.PowerFeedEditView.as_view(), name='powerfeed_add'), + url(r'^power-feeds/import/$', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), + url(r'^power-feeds/edit/$', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + url(r'^power-feeds/delete/$', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), + url(r'^power-feeds/(?P\d+)/$', views.PowerFeedView.as_view(), name='powerfeed'), + url(r'^power-feeds/(?P\d+)/edit/$', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), + url(r'^power-feeds/(?P\d+)/delete/$', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + url(r'^power-feeds/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 27f90a3a2..b1403745d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,6 +3,7 @@ import re from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Count, F @@ -10,6 +11,7 @@ from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape +from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View @@ -30,8 +32,9 @@ from . import filters, forms, tables from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) @@ -391,10 +394,12 @@ class RackView(View): prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() reservations = RackReservation.objects.filter(rack=rack) + power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, 'reservations': reservations, + 'power_feeds': power_feeds, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -910,7 +915,7 @@ class DeviceView(View): consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') + power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') # Power outlets poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') @@ -1670,20 +1675,79 @@ class CableTraceView(View): }) -class CableCreateView(PermissionRequiredMixin, ObjectEditView): +class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): permission_required = 'dcim.add_cable' - model = Cable - model_form = forms.CableCreateForm template_name = 'dcim/cable_connect.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def dispatch(self, request, *args, **kwargs): - # Retrieve endpoint A based on the given type and PK - termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + termination_a_type = kwargs.get('termination_a_type') + termination_a_id = kwargs.get('termination_a_id') - return obj + termination_b_type_name = kwargs.get('termination_b_type') + self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) + + self.obj = Cable( + termination_a=termination_a_type.objects.get(pk=termination_a_id), + termination_b_type=self.termination_b_type + ) + self.form_class = { + 'console-port': forms.ConnectCableToConsolePortForm, + 'console-server-port': forms.ConnectCableToConsoleServerPortForm, + 'power-port': forms.ConnectCableToPowerPortForm, + 'power-outlet': forms.ConnectCableToPowerOutletForm, + 'interface': forms.ConnectCableToInterfaceForm, + 'front-port': forms.ConnectCableToFrontPortForm, + 'power-feed': forms.ConnectCableToPowerFeedForm, + 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, + }[termination_b_type_name] + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + + # Parse initial data manually to avoid setting field values as lists + initial_data = {k: request.GET[k] for k in request.GET} + + form = self.form_class(instance=self.obj, initial=initial_data) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'termination_b_type': self.termination_b_type.name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) + + def post(self, request, *args, **kwargs): + + form = self.form_class(request.POST, request.FILES, instance=self.obj) + + if form.is_valid(): + obj = form.save() + + msg = 'Created cable {}'.format( + obj.get_absolute_url(), + escape(obj) + ) + messages.success(request, mark_safe(msg)) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, obj)) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'termination_b_type': self.termination_b_type.name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) class CableEditView(PermissionRequiredMixin, ObjectEditView): @@ -1760,11 +1824,11 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device' + 'device', '_connected_poweroutlet__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ).order_by( - 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + 'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name' ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm @@ -2114,3 +2178,139 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, 'form': form, 'return_url': self.get_return_url(request, device), }) + + +# +# Power panels +# + +class PowerPanelListView(ObjectListView): + queryset = PowerPanel.objects.select_related( + 'site', 'rack_group' + ).annotate( + powerfeed_count=Count('powerfeeds') + ) + filter = filters.PowerPanelFilter + filter_form = forms.PowerPanelFilterForm + table = tables.PowerPanelTable + template_name = 'dcim/powerpanel_list.html' + + +class PowerPanelView(View): + + def get(self, request, pk): + + powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk) + powerfeed_table = tables.PowerFeedTable( + data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'), + orderable=False + ) + powerfeed_table.exclude = ['power_panel'] + + return render(request, 'dcim/powerpanel.html', { + 'powerpanel': powerpanel, + 'powerfeed_table': powerfeed_table, + }) + + +class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_powerpanel' + model = PowerPanel + model_form = forms.PowerPanelForm + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelEditView(PowerPanelCreateView): + permission_required = 'dcim.change_powerpanel' + + +class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerpanel' + model = PowerPanel + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_powerpanel' + model_form = forms.PowerPanelCSVForm + table = tables.PowerPanelTable + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_powerpanel' + queryset = PowerPanel.objects.select_related( + 'site', 'rack_group' + ).annotate( + rack_count=Count('powerfeeds') + ) + filter = filters.PowerPanelFilter + table = tables.PowerPanelTable + default_return_url = 'dcim:powerpanel_list' + + +# +# Power feeds +# + +class PowerFeedListView(ObjectListView): + queryset = PowerFeed.objects.select_related( + 'power_panel', 'rack' + ) + filter = filters.PowerFeedFilter + filter_form = forms.PowerFeedFilterForm + table = tables.PowerFeedTable + template_name = 'dcim/powerfeed_list.html' + + +class PowerFeedView(View): + + def get(self, request, pk): + + powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk) + + return render(request, 'dcim/powerfeed.html', { + 'powerfeed': powerfeed, + }) + + +class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_powerfeed' + model = PowerFeed + model_form = forms.PowerFeedForm + template_name = 'dcim/powerfeed_edit.html' + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedEditView(PowerFeedCreateView): + permission_required = 'dcim.change_powerfeed' + + +class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerfeed' + model = PowerFeed + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_powerfeed' + model_form = forms.PowerFeedCSVForm + table = tables.PowerFeedTable + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerfeed' + queryset = PowerFeed.objects.select_related('power_panel', 'rack') + filter = filters.PowerFeedFilter + table = tables.PowerFeedTable + form = forms.PowerFeedBulkEditForm + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_powerfeed' + queryset = PowerFeed.objects.select_related('power_panel', 'rack') + filter = filters.PowerFeedFilter + table = tables.PowerFeedTable + default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/migrations/0018_tag_taggeditem.py b/netbox/extras/migrations/0019_tag_taggeditem.py similarity index 96% rename from netbox/extras/migrations/0018_tag_taggeditem.py rename to netbox/extras/migrations/0019_tag_taggeditem.py index 11a3a50a5..7f531a737 100644 --- a/netbox/extras/migrations/0018_tag_taggeditem.py +++ b/netbox/extras/migrations/0019_tag_taggeditem.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0017_exporttemplate_mime_type_length'), + ('extras', '0018_exporttemplate_add_jinja2'), ] operations = [ diff --git a/netbox/extras/migrations/0019_tag_data.py b/netbox/extras/migrations/0020_tag_data.py similarity index 97% rename from netbox/extras/migrations/0019_tag_data.py rename to netbox/extras/migrations/0020_tag_data.py index af4641daa..615ded8e2 100644 --- a/netbox/extras/migrations/0019_tag_data.py +++ b/netbox/extras/migrations/0020_tag_data.py @@ -48,7 +48,7 @@ def delete_taggit_tags(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('extras', '0018_tag_taggeditem'), + ('extras', '0019_tag_taggeditem'), ('circuits', '0015_custom_tag_models'), ('dcim', '0070_custom_tag_models'), ('ipam', '0025_custom_tag_models'), diff --git a/netbox/extras/migrations/0020_add_color_comments_changelog_to_tag.py b/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py similarity index 95% rename from netbox/extras/migrations/0020_add_color_comments_changelog_to_tag.py rename to netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py index 33171db98..26ed34b31 100644 --- a/netbox/extras/migrations/0020_add_color_comments_changelog_to_tag.py +++ b/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py @@ -7,7 +7,7 @@ import utilities.fields class Migration(migrations.Migration): dependencies = [ - ('extras', '0019_tag_data'), + ('extras', '0020_tag_data'), ] operations = [ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index f45415c3c..042ba6e60 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -566,7 +566,7 @@ class TopologyMap(models.Model): from dcim.models import PowerPort # Add all power connections to the graph - for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): + for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices): style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) diff --git a/netbox/ipam/migrations/0025_custom_tag_models.py b/netbox/ipam/migrations/0025_custom_tag_models.py index a47335b58..002aefcb2 100644 --- a/netbox/ipam/migrations/0025_custom_tag_models.py +++ b/netbox/ipam/migrations/0025_custom_tag_models.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0024_vrf_allow_null_rd'), - ('extras', '0018_tag_taggeditem'), + ('extras', '0019_tag_taggeditem'), ] operations = [ diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 837d9473d..a0e8d6a7e 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -166,7 +166,7 @@ class HomeView(View): connected_endpoint__isnull=False ) connected_powerports = PowerPort.objects.filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) connected_interfaces = Interface.objects.filter( _connected_interface__isnull=False, diff --git a/netbox/secrets/migrations/0006_custom_tag_models.py b/netbox/secrets/migrations/0006_custom_tag_models.py index 8e5fc1b80..399dcc5bf 100644 --- a/netbox/secrets/migrations/0006_custom_tag_models.py +++ b/netbox/secrets/migrations/0006_custom_tag_models.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('secrets', '0005_change_logging'), - ('extras', '0018_tag_taggeditem'), + ('extras', '0019_tag_taggeditem'), ] operations = [ diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index cad396966..b1609f578 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -22,7 +22,7 @@ {% endif %} {% with termination_a=form.instance.termination_a %} -

{% block title %}Connect {{ termination_a.device }} {{ termination_a }}{% endblock %}

+

{% block title %}Connect {{ termination_a.device }} {{ termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}

@@ -101,21 +101,43 @@ B Side
- -
- -
- {% render_field form.termination_b_site %} - {% render_field form.termination_b_rack %} + {% if tabs %} + + {% endif %} + {% if 'termination_b_provider' in form.fields %} + {% render_field form.termination_b_provider %} + {% endif %} + {% if 'termination_b_site' in form.fields %} + {% render_field form.termination_b_site %} + {% endif %} + {% if 'termination_b_rackgroup' in form.fields %} + {% render_field form.termination_b_rackgroup %} + {% endif %} + {% if 'termination_b_rack' in form.fields %} + {% render_field form.termination_b_rack %} + {% endif %} + {% if 'termination_b_device' in form.fields %} + {% render_field form.termination_b_device %} + {% endif %} + {% if 'termination_b_type' in form.fields %} + {% render_field form.termination_b_type %} + {% endif %} + {% if 'termination_b_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} +
+ +
+

{{ termination_b_type|capfirst }}

- {% render_field form.termination_b_device %} - {% render_field form.termination_b_type %} {% render_field form.termination_b_id %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a2eddc15f..b9f283837 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -332,6 +332,49 @@ {% endif %}
{% endif %} + {% if power_ports and poweroutlets %} +
+
+ Power Utilization +
+ + + + + + + + {% for pp in power_ports %} + {% for leg in pp.get_power_stats %} + + {% if leg.name %} + + {% else %} + + {% endif %} + + + + + {% endfor %} + {% endfor %} +
InputOutletsAllocated/Max (W)Available (VA)
{{ leg.name }}{{ pp }}{{ leg.outlets|placeholder }}{{ leg.allocated_draw }} / {{ leg.maximum_draw }}{{ leg.available_power }}
+ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} + + {% endif %} +
+ {% endif %} {% if request.user.is_authenticated %}
@@ -627,9 +670,10 @@ {% endif %} Name + Input/Leg Description Cable - Connection + Connection diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index eea23db93..e75e09076 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -4,6 +4,7 @@ {{ cp }} + {# Description #} @@ -38,9 +39,16 @@ {% if cp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleport %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 2ce7818b6..d1dce00d3 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -47,9 +47,16 @@ {% if csp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleserverport %} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 2b468725d..ae86a36b4 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -58,9 +58,17 @@ {% if frontport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_frontport %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 584d1754d..4aa4e377d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -151,9 +151,17 @@ {% if iface.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %} {% elif iface.is_connectable and perms.dcim.add_cable %} - - - + + + + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 74f946495..bebf677f9 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -14,6 +14,15 @@ {{ po }} + {# Power port #} + + {% if po.power_port %} + {{ po.power_port }}{% if po.feed_leg %} / {{ po.get_feed_leg_display }}{% endif %} + {% else %} + None + {% endif %} + + {# Description #} {{ po.description|placeholder }} @@ -30,14 +39,23 @@ {# Connection #} {% if po.connected_endpoint %} - - {{ po.connected_endpoint.device }} - - - {{ po.connected_endpoint }} - + {% with pp=po.connected_endpoint %} + + {{ pp.device }} + + + {{ pp }} + + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + {% endwith %} {% else %} - + Not connected {% endif %} @@ -47,7 +65,7 @@ {% if po.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %} {% elif perms.dcim.add_cable %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index f2fe89330..e8cd77857 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -5,6 +5,15 @@ {{ pp }} + {# Current draw #} + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + {# Description #} {{ pp.description }} @@ -20,13 +29,17 @@ {# Connection #} - {% if pp.connected_endpoint %} + {% if pp.connected_endpoint.device %} {{ pp.connected_endpoint.device }} {{ pp.connected_endpoint }} + {% elif pp.connected_endpoint %} + + {{ pp.connected_endpoint }} + {% else %} Not connected @@ -38,9 +51,15 @@ {% if pp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_powerport %} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 63aae1127..27609e726 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -57,9 +57,17 @@ {% if rearport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_rearport %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html new file mode 100644 index 000000000..f2c16927b --- /dev/null +++ b/netbox/templates/dcim/powerfeed.html @@ -0,0 +1,131 @@ +{% extends '_base.html' %} +{% load static %} +{% load tz %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_powerfeed %} + + + Edit this power feed + + {% endif %} + {% if perms.dcim.delete_powerfeed %} + + + Delete this power feed + + {% endif %} +
+

{% block title %}{{ powerfeed }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=powerfeed %} +{% endblock %} + +{% block content %} +
+
+
+
+ Power Feed +
+ + + + + + + + + + + + + + + + + + + + + +
Power Panel + {{ powerfeed.power_panel }} +
Rack + {% if powerfeed.rack %} + {{ powerfeed.rack }} + {% else %} + None + {% endif %} +
Type + {{ powerfeed.get_type_display }} +
Status + {{ powerfeed.get_status_display }} +
Connected Device + {% if powerfeed.connected_endpoint %} + {{ powerfeed.connected_endpoint.device }} ({{ powerfeed.connected_endpoint }}) + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Electrical Characteristics +
+ + + + + + + + + + + + + + + + + + + + + +
Supply{{ powerfeed.get_supply_display }}
Voltage{{ powerfeed.voltage }}V
Amperage{{ powerfeed.amperage }}A
Phase{{ powerfeed.get_phase_display }}
Power Factor{{ powerfeed.power_factor }}%
+
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html new file mode 100644 index 000000000..bdfbfd77d --- /dev/null +++ b/netbox/templates/dcim/powerfeed_edit.html @@ -0,0 +1,46 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Power Feed
+
+ {% render_field form.site %} + {% render_field form.power_panel %} + {% render_field form.rack %} + {% render_field form.name %} + {% render_field form.status %} +
+
+
+
Characteristics
+
+ {% render_field form.type %} + {% render_field form.supply %} + {% render_field form.voltage %} + {% render_field form.amperage %} + {% render_field form.phase %} + {% render_field form.power_factor %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_list.html b/netbox/templates/dcim/powerfeed_list.html new file mode 100644 index 000000000..cfe2c989c --- /dev/null +++ b/netbox/templates/dcim/powerfeed_list.html @@ -0,0 +1,22 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.dcim.add_powerfeed %} + {% add_button 'dcim:powerfeed_add' %} + {% import_button 'dcim:powerfeed_import' %} + {% endif %} + {% export_button content_type %} +
+

{% block title %}Power Feeds{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html new file mode 100644 index 000000000..88f90ae89 --- /dev/null +++ b/netbox/templates/dcim/powerpanel.html @@ -0,0 +1,80 @@ +{% extends '_base.html' %} +{% load static %} +{% load tz %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_powerpanel %} + + + Edit this power panel + + {% endif %} + {% if perms.dcim.delete_powerpanel %} + + + Delete this power panel + + {% endif %} +
+

{% block title %}{{ powerpanel }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=powerpanel %} +{% endblock %} + +{% block content %} +
+
+
+
+ Power Panel +
+ + + + + + + + + +
Site + {{ powerpanel.site }} +
Rack Group + {% if powerpanel.rack_group %} + {{ powerpanel.rack_group }} + {% else %} + None + {% endif %} +
+
+
+
+ {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerpanel_list.html b/netbox/templates/dcim/powerpanel_list.html new file mode 100644 index 000000000..a0d49b30b --- /dev/null +++ b/netbox/templates/dcim/powerpanel_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.dcim.add_powerpanel %} + {% add_button 'dcim:powerpanel_add' %} + {% import_button 'dcim:powerpanel_import' %} + {% endif %} + {% export_button content_type %} +
+

{% block title %}Power Panels{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 68ea75b6c..e702405a2 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -190,47 +190,37 @@ {% endif %}
-
-
- Non-Racked Devices -
- {% if nonracked_devices %} - + {% if power_feeds %} +
+
+ Power Feeds +
+
- - + + + - - {% for device in nonracked_devices %} - + {% for powerfeed in power_feeds %} + - - + {% endfor %}
NameRolePanelFeedStatus TypeParent
- {{ device }} + {{ powerfeed.power_panel.name }} + + + {{ powerfeed.name }} {{ device.device_role }}{{ device.device_type.display_name }} - {% if device.parent_bay %} - {{ device.parent_bay }} - {% else %} - - {% endif %} + {{ powerfeed.get_status_display }} + + {{ powerfeed.get_type_display }}
- {% else %} -
None
- {% endif %} - {% if perms.dcim.add_device %} - - {% endif %} -
+
+ {% endif %}
Images @@ -299,19 +289,62 @@ {% endif %}
-
-
-
-

Front

-
- {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} -
-
-
-

Rear

+
+
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} +
+
+
+
+ Non-Racked Devices +
+ {% if nonracked_devices %} + + + + + + + + {% for device in nonracked_devices %} + + + + + + + {% endfor %} +
NameRoleTypeParent
+ {{ device }} + {{ device.device_role }}{{ device.device_type.display_name }} + {% if device.parent_bay %} + {{ device.parent_bay }} + {% else %} + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% if perms.dcim.add_device %} + + {% endif %}
- {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} -
{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 5f8f371d3..a08eb96e0 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -368,6 +368,29 @@ + {% if request.user.is_authenticated %}