Просмотр исходного кода

Merge pull request #3061 from digitalocean/54-power-modeling

#54: Power modeling
Jeremy Stretch 6 лет назад
Родитель
Сommit
ea6815b9bb
42 измененных файлов с 2453 добавлено и 201 удалено
  1. 1 1
      netbox/circuits/migrations/0015_custom_tag_models.py
  2. 1 1
      netbox/circuits/urls.py
  3. 24 2
      netbox/dcim/api/nested_serializers.py
  4. 83 9
      netbox/dcim/api/serializers.py
  5. 4 0
      netbox/dcim/api/urls.py
  6. 27 4
      netbox/dcim/api/views.py
  7. 39 1
      netbox/dcim/constants.py
  8. 88 4
      netbox/dcim/filters.py
  9. 25 25
      netbox/dcim/fixtures/dcim.json
  10. 560 28
      netbox/dcim/forms.py
  11. 1 1
      netbox/dcim/migrations/0070_custom_tag_models.py
  12. 133 0
      netbox/dcim/migrations/0072_powerfeeds.py
  13. 317 4
      netbox/dcim/models.py
  14. 54 2
      netbox/dcim/tables.py
  15. 259 2
      netbox/dcim/tests/test_api.py
  16. 30 8
      netbox/dcim/urls.py
  17. 215 15
      netbox/dcim/views.py
  18. 1 1
      netbox/extras/migrations/0019_tag_taggeditem.py
  19. 1 1
      netbox/extras/migrations/0020_tag_data.py
  20. 1 1
      netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py
  21. 1 1
      netbox/extras/models.py
  22. 1 1
      netbox/ipam/migrations/0025_custom_tag_models.py
  23. 1 1
      netbox/netbox/views.py
  24. 1 1
      netbox/secrets/migrations/0006_custom_tag_models.py
  25. 36 14
      netbox/templates/dcim/cable_connect.html
  26. 45 1
      netbox/templates/dcim/device.html
  27. 11 3
      netbox/templates/dcim/inc/consoleport.html
  28. 10 3
      netbox/templates/dcim/inc/consoleserverport.html
  29. 11 3
      netbox/templates/dcim/inc/frontport.html
  30. 11 3
      netbox/templates/dcim/inc/interface.html
  31. 26 8
      netbox/templates/dcim/inc/poweroutlet.html
  32. 23 4
      netbox/templates/dcim/inc/powerport.html
  33. 11 3
      netbox/templates/dcim/inc/rearport.html
  34. 131 0
      netbox/templates/dcim/powerfeed.html
  35. 46 0
      netbox/templates/dcim/powerfeed_edit.html
  36. 22 0
      netbox/templates/dcim/powerfeed_list.html
  37. 80 0
      netbox/templates/dcim/powerpanel.html
  38. 21 0
      netbox/templates/dcim/powerpanel_list.html
  39. 76 43
      netbox/templates/dcim/rack.html
  40. 23 0
      netbox/templates/inc/nav_menu.html
  41. 1 1
      netbox/tenancy/migrations/0006_custom_tag_models.py
  42. 1 1
      netbox/virtualization/migrations/0009_custom_tag_models.py

+ 1 - 1
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 = [

+ 1 - 1
netbox/circuits/urls.py

@@ -43,7 +43,7 @@ urlpatterns = [
     url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
     url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
     url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
+    url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
     url(r'^circuit-terminations/(?P<pk>\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 ]

+ 24 - 2
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']

+ 83 - 9
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',
+        ]

+ 4 - 0
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')
 

+ 27 - 4
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
 #

+ 39 - 1
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'),
+)

+ 88 - 4
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)

+ 25 - 25
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
     }
 },

+ 560 - 28
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/console-ports/',
+            disabled_indicator='cable',
+        )
     )
+
+
+class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
     termination_b_id = forms.IntegerField(
         label='Name',
         widget=APISelect(
-            api_url='/api/dcim/{{termination_b_type}}s/',
+            api_url='/api/dcim/console-server-ports/',
             disabled_indicator='cable',
-            conditional_query_params={
-                'termination_b_type__interface': 'type=physical',
+        )
+    )
+
+
+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
+    )

+ 1 - 1
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 = [

+ 133 - 0
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'),
+        ),
+    ]

+ 317 - 4
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

+ 54 - 2
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 = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 
+TYPE_LABEL = """
+<span class="label label-{{ record.get_type_class }}">{{ record.get_type_display }}</span>
+"""
+
 DEVICE_PRIMARY_IP = """
 {{ record.primary_ip6.address.ip|default:"" }}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% 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')

+ 259 - 2
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)

+ 30 - 8
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<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    url(r'^console-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
+    url(r'^console-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     url(r'^console-ports/(?P<pk>\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<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
     url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
+    url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
     url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     url(r'^console-server-ports/(?P<pk>\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<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
     url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    url(r'^power-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
+    url(r'^power-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
     url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     url(r'^power-ports/(?P<pk>\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<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
     url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
+    url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
     url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     url(r'^power-outlets/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
@@ -202,7 +203,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
     url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^interfaces/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
+    url(r'^interfaces/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
     url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
     url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
     url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
@@ -217,7 +218,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'),
     url(r'^devices/(?P<pk>\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
-    url(r'^front-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
+    url(r'^front-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
     url(r'^front-ports/(?P<pk>\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'),
     url(r'^front-ports/(?P<pk>\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
     url(r'^front-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
@@ -229,7 +230,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'),
     url(r'^devices/(?P<pk>\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
-    url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
+    url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     url(r'^rear-ports/(?P<pk>\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'),
     url(r'^rear-ports/(?P<pk>\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     url(r'^rear-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
@@ -279,4 +280,25 @@ urlpatterns = [
     url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     url(r'^virtual-chassis-members/(?P<pk>\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<pk>\d+)/$', views.PowerPanelView.as_view(), name='powerpanel'),
+    url(r'^power-panels/(?P<pk>\d+)/edit/$', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
+    url(r'^power-panels/(?P<pk>\d+)/delete/$', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
+    url(r'^power-panels/(?P<pk>\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<pk>\d+)/$', views.PowerFeedView.as_view(), name='powerfeed'),
+    url(r'^power-feeds/(?P<pk>\d+)/edit/$', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
+    url(r'^power-feeds/(?P<pk>\d+)/delete/$', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
+    url(r'^power-feeds/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
+
 ]

+ 215 - 15
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 <a href="{}">{}</a>'.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'

+ 1 - 1
netbox/extras/migrations/0018_tag_taggeditem.py → 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 = [

+ 1 - 1
netbox/extras/migrations/0019_tag_data.py → 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'),

+ 1 - 1
netbox/extras/migrations/0020_add_color_comments_changelog_to_tag.py → 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 = [

+ 1 - 1
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)
 

+ 1 - 1
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 = [

+ 1 - 1
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,

+ 1 - 1
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 = [

+ 36 - 14
netbox/templates/dcim/cable_connect.html

@@ -22,7 +22,7 @@
         </div>
     {% endif %}
     {% with termination_a=form.instance.termination_a %}
-        <h3>{% block title %}Connect {{ termination_a.device }} {{ termination_a }}{% endblock %}</h3>
+        <h3>{% block title %}Connect {{ termination_a.device }} {{ termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}</h3>
         <div class="row">
             <div class="col-md-5">
                 <div class="panel panel-default">
@@ -101,21 +101,43 @@
                         <strong>B Side</strong>
                     </div>
                     <div class="panel-body">
-                        <ul class="nav nav-tabs" role="tablist">
-                            <li role="presentation" class="active"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
-                            <li role="presentation"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
-                        </ul>
-                        <div class="tab-content">
-                            <div class="tab-pane active" id="search">
-                                &nbsp;
-                            </div>
-                            <div class="tab-pane" id="select">
-                                {% render_field form.termination_b_site %}
-                                {% render_field form.termination_b_rack %}
+                        {% if tabs %}
+                            <ul class="nav nav-tabs">
+                                {% for url, link in tabs %}
+                                    <li role="presentation"><a href="{{ url }}">{{ link }}</a></li>
+                                {% endfor %}
+                            </ul>
+                        {% 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 %}
+                        <div class="form-group">
+                            <label class="col-md-3 control-label required">Type</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ termination_b_type|capfirst }}</p>
                             </div>
                         </div>
-                        {% render_field form.termination_b_device %}
-                        {% render_field form.termination_b_type %}
                         {% render_field form.termination_b_id %}
                     </div>
                 </div>

+ 45 - 1
netbox/templates/dcim/device.html

@@ -332,6 +332,49 @@
                     {% endif %}
                 </div>
             {% endif %}
+            {% if power_ports and poweroutlets %}
+                <div class="panel panel-default">
+                    <div class="panel-heading">
+                        <strong>Power Utilization</strong>
+                    </div>
+                    <table class="table table-hover panel-body">
+                        <tr>
+                            <th>Input</th>
+                            <th>Outlets</th>
+                            <th>Allocated/Max (W)</th>
+                            <th>Available (VA)</th>
+                        </tr>
+                        {% for pp in power_ports %}
+                            {% for leg in pp.get_power_stats %}
+                                <tr>
+                                    {% if leg.name %}
+                                        <td style="padding-left: 20px">{{ leg.name }}</td>
+                                    {% else %}
+                                        <td>{{ pp }}</td>
+                                    {% endif %}
+                                    <td>{{ leg.outlets|placeholder }}</td>
+                                    <td>{{ leg.allocated_draw }} / {{ leg.maximum_draw }}</td>
+                                    <td>{{ leg.available_power }}</td>
+                                </tr>
+                            {% endfor %}
+                        {% endfor %}
+                    </table>
+                    {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
+                        <div class="panel-footer text-right noprint">
+                            {% if perms.dcim.add_consoleport %}
+                                <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
+                                </a>
+                            {% endif %}
+                            {% if perms.dcim.add_powerport %}
+                                <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
+                                </a>
+                            {% endif %}
+                        </div>
+                    {% endif %}
+                </div>
+            {% endif %}
             {% if request.user.is_authenticated %}
                 <div class="panel panel-default">
                     <div class="panel-heading">
@@ -627,9 +670,10 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 <th>Name</th>
+                                <th>Input/Leg</th>
                                 <th>Description</th>
                                 <th>Cable</th>
-                                <th colspan="2">Connection</th>
+                                <th colspan="3">Connection</th>
                                 <th></th>
                             </tr>
                         </thead>

+ 11 - 3
netbox/templates/dcim/inc/consoleport.html

@@ -4,6 +4,7 @@
     <td>
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
     </td>
+    <td></td>
 
     {# Description #}
     <td>
@@ -38,9 +39,16 @@
         {% if cp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %}
         {% elif perms.dcim.add_cable %}
-            <a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
-                <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-            </a>
+            <span class="dropdown">
+                <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                </button>
+                <ul class="dropdown-menu dropdown-menu-right">
+                    <li><a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk termination_b_type='console-server-port' %}?return_url={{ device.get_absolute_url }}">Console Server Port</a></li>
+                    <li><a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
+                    <li><a href="{% url 'dcim:consoleport_connect' termination_a_id=cp.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
+                </ul>
+            </span>
         {% endif %}
         {% if perms.dcim.change_consoleport %}
             <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">

+ 10 - 3
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 %}
-            <a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
-                <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-            </a>
+            <span class="dropdown">
+                <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                </button>
+                <ul class="dropdown-menu dropdown-menu-right">
+                    <li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk termination_b_type='console-port' %}?return_url={{ device.get_absolute_url }}">Console Port</a></li>
+                    <li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
+                    <li><a href="{% url 'dcim:consoleserverport_connect' termination_a_id=csp.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
+                </ul>
+            </span>
         {% endif %}
         {% if perms.dcim.change_consoleserverport %}
             <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">

+ 11 - 3
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 %}
-            <a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
-                <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-            </a>
+            <span class="dropdown">
+                <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                </button>
+                <ul class="dropdown-menu dropdown-menu-right">
+                    <li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
+                    <li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
+                    <li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
+                    <li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
+                </ul>
+            </span>
         {% endif %}
         {% if perms.dcim.change_frontport %}
             <a href="{% url 'dcim:frontport_edit' pk=frontport.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">

+ 11 - 3
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 %}
-                <a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
-                    <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-                </a>
+                <span class="dropdown">
+                    <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                        <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                    </button>
+                    <ul class="dropdown-menu dropdown-menu-right">
+                        <li><a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
+                        <li><a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
+                        <li><a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
+                        <li><a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
+                    </ul>
+                </span>
             {% endif %}
             <a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>

+ 26 - 8
netbox/templates/dcim/inc/poweroutlet.html

@@ -14,6 +14,15 @@
         <i class="fa fa-fw fa-bolt"></i> {{ po }}
     </td>
 
+    {# Power port #}
+    <td>
+        {% if po.power_port %}
+            {{ po.power_port }}{% if po.feed_leg %} / {{ po.get_feed_leg_display }}{% endif %}
+        {% else %}
+            <span class="text-warning">None</span>
+        {% endif %}
+    </td>
+
     {# Description #}
     <td>
         {{ po.description|placeholder }}
@@ -30,14 +39,23 @@
 
     {# Connection #}
     {% if po.connected_endpoint %}
-        <td>
-            <a href="{% url 'dcim:device' pk=po.connected_endpoint.device.pk %}">{{ po.connected_endpoint.device }}</a>
-        </td>
-        <td>
-            {{ po.connected_endpoint }}
-        </td>
+        {% with pp=po.connected_endpoint %}
+            <td>
+                <a href="{% url 'dcim:device' pk=pp.device.pk %}">{{ pp.device }}</a>
+            </td>
+            <td>
+                {{ pp }}
+            </td>
+            <td>
+                {% 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 %}
+            </td>
+        {% endwith %}
     {% else %}
-        <td colspan="2">
+        <td colspan="3">
             <span class="text-muted">Not connected</span>
         </td>
     {% endif %}
@@ -47,7 +65,7 @@
         {% if po.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %}
         {% elif perms.dcim.add_cable %}
-            <a href="{% url 'dcim:poweroutlet_connect' termination_a_id=po.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
+            <a href="{% url 'dcim:poweroutlet_connect' termination_a_id=po.pk termination_b_type='power-outlet' %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
                 <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
             </a>
         {% endif %}

+ 23 - 4
netbox/templates/dcim/inc/powerport.html

@@ -5,6 +5,15 @@
         <i class="fa fa-fw fa-bolt"></i> {{ pp }}
     </td>
 
+    {# Current draw #}
+    <td>
+        {% 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 %}
+    </td>
+
     {# Description #}
     <td>
         {{ pp.description }}
@@ -20,13 +29,17 @@
     </td>
 
     {# Connection #}
-    {% if pp.connected_endpoint %}
+    {% if pp.connected_endpoint.device %}
         <td>
             <a href="{% url 'dcim:device' pk=pp.connected_endpoint.device.pk %}">{{ pp.connected_endpoint.device }}</a>
         </td>
         <td>
             {{ pp.connected_endpoint }}
         </td>
+    {% elif pp.connected_endpoint %}
+        <td colspan="2">
+            <a href="{{ pp.connected_endpoint.get_absolute_url }}">{{ pp.connected_endpoint }}</a>
+        </td>
     {% else %}
         <td colspan="2">
             <span class="text-muted">Not connected</span>
@@ -38,9 +51,15 @@
         {% if pp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %}
         {% elif perms.dcim.add_cable %}
-            <a href="{% url 'dcim:powerport_connect' termination_a_id=pp.pk %}?return_url={{ device.get_absolute_url }}" title="Connect" class="btn btn-success btn-xs">
-                <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-            </a>
+            <span class="dropdown">
+                <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                </button>
+                <ul class="dropdown-menu dropdown-menu-right">
+                    <li><a href="{% url 'dcim:powerport_connect' termination_a_id=pp.pk termination_b_type='power-outlet' %}?return_url={{ device.get_absolute_url }}">Power Outlet</a></li>
+                    <li><a href="{% url 'dcim:powerport_connect' termination_a_id=pp.pk termination_b_type='power-feed' %}?return_url={{ device.get_absolute_url }}">Power Feed</a></li>
+                </ul>
+            </span>
         {% endif %}
         {% if perms.dcim.change_powerport %}
             <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">

+ 11 - 3
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 %}
-            <a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
-                <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
-            </a>
+            <span class="dropdown">
+                <button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span>
+                </button>
+                <ul class="dropdown-menu dropdown-menu-right">
+                    <li><a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
+                    <li><a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
+                    <li><a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
+                    <li><a href="{% url 'dcim:rearport_connect' termination_a_id=rearport.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
+                </ul>
+            </span>
         {% endif %}
         {% if perms.dcim.change_rearport %}
             <a href="{% url 'dcim:rearport_edit' pk=rearport.pk %}?return_url={{ device.get_absolute_url }}" title="Edit port" class="btn btn-info btn-xs">

+ 131 - 0
netbox/templates/dcim/powerfeed.html

@@ -0,0 +1,131 @@
+{% extends '_base.html' %}
+{% load static %}
+{% load tz %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row noprint">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a></li>
+                <li><a href="{{ powerfeed.power_panel.site.get_absolute_url }}">{{ powerfeed.power_panel.site }}</a></li>
+                <li><a href="{{ powerfeed.power_panel.get_absolute_url }}">{{ powerfeed.power_panel }}</a></li>
+                {% if powerfeed.rack %}
+                    <li><a href="{{ powerfeed.rack.get_absolute_url }}">{{ powerfeed.rack }}</a></li>
+                {% endif %}
+                <li>{{ powerfeed }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:powerfeed_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search power feeds" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right noprint">
+        {% if perms.dcim.change_powerfeed %}
+            <a href="{% url 'dcim:powerfeed_edit' pk=powerfeed.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this power feed
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_powerfeed %}
+            <a href="{% url 'dcim:powerfeed_delete' pk=powerfeed.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this power feed
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ powerfeed }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=powerfeed %}
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Power Feed</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Power Panel</td>
+                    <td>
+                        <a href="{{ powerfeed.power_panel.get_absolute_url }}">{{ powerfeed.power_panel }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Rack</td>
+                    <td>
+                        {% if powerfeed.rack %}
+                            <a href="{{ powerfeed.rack.get_absolute_url }}">{{ powerfeed.rack }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Type</td>
+                    <td>
+                        <span class="label label-{{ powerfeed.get_type_class }}">{{ powerfeed.get_type_display }}</span>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Status</td>
+                    <td>
+                        <span class="label label-{{ powerfeed.get_status_class }}">{{ powerfeed.get_status_display }}</span>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Connected Device</td>
+                    <td>
+                        {% if powerfeed.connected_endpoint %}
+                            <a href="{{ powerfeed.connected_endpoint.device.get_absolute_url }}">{{ powerfeed.connected_endpoint.device }}</a> ({{ powerfeed.connected_endpoint }})
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+    </div>
+    <div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Electrical Characteristics</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Supply</td>
+                    <td>{{ powerfeed.get_supply_display }}</td>
+                </tr>
+                <tr>
+                    <td>Voltage</td>
+                    <td>{{ powerfeed.voltage }}V</td>
+                </tr>
+                <tr>
+                    <td>Amperage</td>
+                    <td>{{ powerfeed.amperage }}A</td>
+                </tr>
+                <tr>
+                    <td>Phase</td>
+                    <td>{{ powerfeed.get_phase_display }}</td>
+                </tr>
+                <tr>
+                    <td>Power Factor</td>
+                    <td>{{ powerfeed.power_factor }}%</td>
+                </tr>
+            </table>
+        </div>
+    </div>
+    <div class="col-md-5">
+	</div>
+</div>
+{% endblock %}

+ 46 - 0
netbox/templates/dcim/powerfeed_edit.html

@@ -0,0 +1,46 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Power Feed</strong></div>
+        <div class="panel-body">
+            {% render_field form.site %}
+            {% render_field form.power_panel %}
+            {% render_field form.rack %}
+            {% render_field form.name %}
+            {% render_field form.status %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Characteristics</strong></div>
+        <div class="panel-body">
+            {% 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 %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tags</strong></div>
+        <div class="panel-body">
+            {% render_field form.tags %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

+ 22 - 0
netbox/templates/dcim/powerfeed_list.html

@@ -0,0 +1,22 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% if perms.dcim.add_powerfeed %}
+        {% add_button 'dcim:powerfeed_add' %}
+        {% import_button 'dcim:powerfeed_import' %}
+    {% endif %}
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Power Feeds{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+		{% include 'inc/tags_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 80 - 0
netbox/templates/dcim/powerpanel.html

@@ -0,0 +1,80 @@
+{% extends '_base.html' %}
+{% load static %}
+{% load tz %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row noprint">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:powerpanel_list' %}">Power Panels</a></li>
+                <li><a href="{{ powerpanel.site.get_absolute_url }}">{{ powerpanel.site }}</a></li>
+                {% if powerpanel.rack_group %}
+                    <li><a href="{{ powerpanel.rack_group.get_absolute_url }}">{{ powerpanel.rack_group }}</a></li>
+                {% endif %}
+                <li>{{ powerpanel }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:powerpanel_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search power panels" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right noprint">
+        {% if perms.dcim.change_powerpanel %}
+            <a href="{% url 'dcim:powerpanel_edit' pk=powerpanel.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span>
+                Edit this power panel
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_powerpanel %}
+            <a href="{% url 'dcim:powerpanel_delete' pk=powerpanel.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span>
+                Delete this power panel
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ powerpanel }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=powerpanel %}
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-3">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Power Panel</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Site</td>
+                    <td>
+                        <a href="{{ powerpanel.site.get_absolute_url }}">{{ powerpanel.site }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Rack Group</td>
+                    <td>
+                        {% if powerpanel.rack_group %}
+                            <a href="{{ powerpanel.rack_group.get_absolute_url }}">{{ powerpanel.rack_group }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+    </div>
+	<div class="col-md-9">
+        {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %}
+    </div>
+</div>
+{% endblock %}

+ 21 - 0
netbox/templates/dcim/powerpanel_list.html

@@ -0,0 +1,21 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% if perms.dcim.add_powerpanel %}
+        {% add_button 'dcim:powerpanel_add' %}
+        {% import_button 'dcim:powerpanel_import' %}
+    {% endif %}
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Power Panels{% endblock %}</h1>
+<div class="row">
+    <div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 76 - 43
netbox/templates/dcim/rack.html

@@ -190,47 +190,37 @@
                 {% endif %}
             </div>
         </div>
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Non-Racked Devices</strong>
-            </div>
-            {% if nonracked_devices %}
-                <table class="table table-hover panel-body">
+        {% if power_feeds %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Power Feeds</strong>
+                </div>
+                <table class="table panel-body">
                     <tr>
-                        <th>Name</th>
-                        <th>Role</th>
+                        <th>Panel</th>
+                        <th>Feed</th>
+                        <th>Status</th>
                         <th>Type</th>
-                        <th>Parent</th>
                     </tr>
-                    {% for device in nonracked_devices %}
-                        <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
+                    {% for powerfeed in power_feeds %}
+                        <tr>
                             <td>
-                                <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
+                                <a href="{{ powerfeed.power_panel.get_absolute_url }}">{{ powerfeed.power_panel.name }}</a>
+
+                            <td>
+                                <a href="{{ powerfeed.get_absolute_url }}">{{ powerfeed.name }}</a>
                             </td>
-                            <td>{{ device.device_role }}</td>
-                            <td>{{ device.device_type.display_name }}</td>
                             <td>
-                                {% if device.parent_bay %}
-                                    <a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
+                                <span class="label label-{{ powerfeed.get_status_class }}">{{ powerfeed.get_status_display }}</span>
+                            </td>
+                            <td>
+                                <span class="label label-{{ powerfeed.get_type_class }}">{{ powerfeed.get_type_display }}</span>
                             </td>
                         </tr>
                     {% endfor %}
                 </table>
-            {% else %}
-                <div class="panel-body text-muted">None</div>
-            {% endif %}
-            {% if perms.dcim.add_device %}
-                <div class="panel-footer text-right noprint">
-                    <a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}" class="btn btn-primary btn-xs">
-                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                        Add a non-racked device
-                    </a>
-                </div>
-            {% endif %}
-        </div>
+            </div>
+        {% endif %}
         <div class="panel panel-default">
             <div class="panel-heading">
                 <strong>Images</strong>
@@ -299,19 +289,62 @@
             {% endif %}
         </div>
 	</div>
-    <div class="row col-md-6">
-       <div class="col-md-6 col-sm-6 col-xs-12">
-          <div class="rack_header">
-            <h4>Front</h4>
-          </div>
-          {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %}
-      </div>
-      <div class="col-md-6 col-sm-6 col-xs-12">
-        <div class="rack_header">
-            <h4>Rear</h4>
+    <div class="col-md-6">
+        <div class="row" style="margin-bottom: 20px">
+            <div class="col-md-6 col-sm-6 col-xs-12">
+                <div class="rack_header">
+                    <h4>Front</h4>
+                </div>
+                {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %}
+            </div>
+            <div class="col-md-6 col-sm-6 col-xs-12">
+                <div class="rack_header">
+                    <h4>Rear</h4>
+                </div>
+                {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %}
+            </div>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Non-Racked Devices</strong>
+            </div>
+            {% if nonracked_devices %}
+                <table class="table table-hover panel-body">
+                    <tr>
+                        <th>Name</th>
+                        <th>Role</th>
+                        <th>Type</th>
+                        <th>Parent</th>
+                    </tr>
+                    {% for device in nonracked_devices %}
+                        <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
+                            <td>
+                                <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
+                            </td>
+                            <td>{{ device.device_role }}</td>
+                            <td>{{ device.device_type.display_name }}</td>
+                            <td>
+                                {% if device.parent_bay %}
+                                    <a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
+                                {% else %}
+                                    <span class="text-muted">&mdash;</span>
+                                {% endif %}
+                            </td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">None</div>
+            {% endif %}
+            {% if perms.dcim.add_device %}
+                <div class="panel-footer text-right noprint">
+                    <a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}" class="btn btn-primary btn-xs">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                        Add a non-racked device
+                    </a>
+                </div>
+            {% endif %}
         </div>
-        {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %}
-      </div>
     </div>
 </div>
 {% endblock %}

+ 23 - 0
netbox/templates/inc/nav_menu.html

@@ -368,6 +368,29 @@
                         </li>
                     </ul>
                 </li>
+                <li class="dropdown{% if request.path|contains:'/dcim/power' %} active{% endif %}">
+                    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Power <span class="caret"></span></a>
+                    <ul class="dropdown-menu">
+                        <li>
+                            {% if perms.dcim.add_powerfeed %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:powerfeed_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'dcim:powerfeed_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'dcim:powerfeed_list' %}">Power Feeds</a>
+                        </li>
+                        <li>
+                            {% if perms.dcim.add_powerpanel %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'dcim:powerpanel_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                    <a href="{% url 'dcim:powerpanel_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'dcim:powerpanel_list' %}">Power Panels</a>
+                        </li>
+                    </ul>
+                </li>
                 {% if request.user.is_authenticated %}
                     <li class="dropdown{% if request.path|contains:'/secrets/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>

+ 1 - 1
netbox/tenancy/migrations/0006_custom_tag_models.py

@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('tenancy', '0005_change_logging'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
 
     operations = [

+ 1 - 1
netbox/virtualization/migrations/0009_custom_tag_models.py

@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('virtualization', '0008_virtualmachine_local_context_data'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
 
     operations = [