Quellcode durchsuchen

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

#54: Power modeling
Jeremy Stretch vor 6 Jahren
Ursprung
Commit
ea6815b9bb
42 geänderte Dateien mit 2453 neuen und 201 gelöschten Zeilen
  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 = [
     dependencies = [
         ('circuits', '0014_circuittermination_description'),
         ('circuits', '0014_circuittermination_description'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
     ]
 
 
     operations = [
     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'^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+)/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<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}),
     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.constants import CONNECTION_STATUS_CHOICES
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
     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
 from utilities.api import ChoiceField, WritableNestedSerializer
 
 
@@ -21,7 +21,9 @@ __all__ = [
     'NestedInterfaceSerializer',
     'NestedInterfaceSerializer',
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
     'NestedPlatformSerializer',
     'NestedPlatformSerializer',
+    'NestedPowerFeedSerializer',
     'NestedPowerOutletSerializer',
     'NestedPowerOutletSerializer',
+    'NestedPowerPanelSerializer',
     'NestedPowerPortSerializer',
     'NestedPowerPortSerializer',
     'NestedRackGroupSerializer',
     'NestedRackGroupSerializer',
     'NestedRackRoleSerializer',
     'NestedRackRoleSerializer',
@@ -247,3 +249,23 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
         fields = ['id', 'url', 'master']
         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 (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     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 extras.api.customfields import CustomFieldModelSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
@@ -209,15 +210,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
-        fields = ['id', 'device_type', 'name']
+        fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw']
 
 
 
 
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
+    power_port = PowerPortTemplateSerializer(
+        required=False
+    )
+    feed_leg = ChoiceField(
+        choices=POWERFEED_LEG_CHOICES,
+        required=False,
+        allow_null=True
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
-        fields = ['id', 'device_type', 'name']
+        fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg']
 
 
 
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -371,14 +380,26 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 
 
 class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     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:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         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:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         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',
             'cable', 'tags',
         ]
         ]
 
 
@@ -592,3 +613,56 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
         fields = ['id', 'master', 'domain', 'tags']
         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
 # Virtual chassis
 router.register(r'virtual-chassis', views.VirtualChassisViewSet)
 router.register(r'virtual-chassis', views.VirtualChassisViewSet)
 
 
+# Power
+router.register(r'power-panels', views.PowerPanelViewSet)
+router.register(r'power-feeds', views.PowerFeedViewSet)
+
 # Miscellaneous
 # Miscellaneous
 router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 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 (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     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.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
@@ -43,6 +44,8 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
         (FrontPortTemplate, ['type']),
         (FrontPortTemplate, ['type']),
         (Interface, ['form_factor', 'mode']),
         (Interface, ['form_factor', 'mode']),
         (InterfaceTemplate, ['form_factor']),
         (InterfaceTemplate, ['form_factor']),
+        (PowerOutlet, ['feed_leg']),
+        (PowerOutletTemplate, ['feed_leg']),
         (PowerPort, ['connection_status']),
         (PowerPort, ['connection_status']),
         (Rack, ['outer_unit', 'status', 'type', 'width']),
         (Rack, ['outer_unit', 'status', 'type', 'width']),
         (RearPort, ['type']),
         (RearPort, ['type']),
@@ -407,7 +410,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
 
 
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
+        'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable'
     ).prefetch_related(
     ).prefetch_related(
         'tags'
         'tags'
     )
     )
@@ -497,7 +500,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
-        connected_endpoint__isnull=False
+        _connected_poweroutlet__isnull=False
     )
     )
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filters.PowerConnectionFilter
     filterset_class = filters.PowerConnectionFilter
@@ -536,6 +539,26 @@ class VirtualChassisViewSet(ModelViewSet):
     serializer_class = serializers.VirtualChassisSerializer
     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
 # Miscellaneous
 #
 #

+ 39 - 1
netbox/dcim/constants.py

@@ -422,7 +422,7 @@ CABLE_TERMINATION_TYPE_CHOICES = {
 COMPATIBLE_TERMINATION_TYPES = {
 COMPATIBLE_TERMINATION_TYPES = {
     'consoleport': ['consoleserverport', 'frontport', 'rearport'],
     'consoleport': ['consoleserverport', 'frontport', 'rearport'],
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
-    'powerport': ['poweroutlet'],
+    'powerport': ['poweroutlet', 'powerfeed'],
     'poweroutlet': ['powerport'],
     'poweroutlet': ['powerport'],
     'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
@@ -445,3 +445,41 @@ RACK_DIMENSION_UNIT_CHOICES = (
     (LENGTH_UNIT_MILLIMETER, 'Millimeters'),
     (LENGTH_UNIT_MILLIMETER, 'Millimeters'),
     (LENGTH_UNIT_INCH, 'Inches'),
     (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 (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     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']
         fields = ['name', 'slug']
 
 
 
 
-class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
+class SiteFilter(CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -122,7 +123,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
         fields = ['name', 'slug', 'color']
         fields = ['name', 'slug', 'color']
 
 
 
 
-class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
+class RackFilter(CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'
@@ -1065,3 +1066,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             Q(device__name__icontains=value) |
             Q(device__name__icontains=value) |
             Q(_connected_interface__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": {
     "fields": {
         "device": 1,
         "device": 1,
         "name": "PEM0",
         "name": "PEM0",
-        "connected_endpoint": 25,
+        "_connected_poweroutlet": 25,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2677,7 +2677,7 @@
     "fields": {
     "fields": {
         "device": 1,
         "device": 1,
         "name": "PEM1",
         "name": "PEM1",
-        "connected_endpoint": 49,
+        "_connected_poweroutlet": 49,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2687,7 +2687,7 @@
     "fields": {
     "fields": {
         "device": 1,
         "device": 1,
         "name": "PEM2",
         "name": "PEM2",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2697,7 +2697,7 @@
     "fields": {
     "fields": {
         "device": 1,
         "device": 1,
         "name": "PEM3",
         "name": "PEM3",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2707,7 +2707,7 @@
     "fields": {
     "fields": {
         "device": 2,
         "device": 2,
         "name": "PEM0",
         "name": "PEM0",
-        "connected_endpoint": 26,
+        "_connected_poweroutlet": 26,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2717,7 +2717,7 @@
     "fields": {
     "fields": {
         "device": 2,
         "device": 2,
         "name": "PEM1",
         "name": "PEM1",
-        "connected_endpoint": 50,
+        "_connected_poweroutlet": 50,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2727,7 +2727,7 @@
     "fields": {
     "fields": {
         "device": 2,
         "device": 2,
         "name": "PEM2",
         "name": "PEM2",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2737,7 +2737,7 @@
     "fields": {
     "fields": {
         "device": 2,
         "device": 2,
         "name": "PEM3",
         "name": "PEM3",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2747,7 +2747,7 @@
     "fields": {
     "fields": {
         "device": 4,
         "device": 4,
         "name": "PSU0",
         "name": "PSU0",
-        "connected_endpoint": 28,
+        "_connected_poweroutlet": 28,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2757,7 +2757,7 @@
     "fields": {
     "fields": {
         "device": 4,
         "device": 4,
         "name": "PSU1",
         "name": "PSU1",
-        "connected_endpoint": 52,
+        "_connected_poweroutlet": 52,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2767,7 +2767,7 @@
     "fields": {
     "fields": {
         "device": 5,
         "device": 5,
         "name": "PSU0",
         "name": "PSU0",
-        "connected_endpoint": 56,
+        "_connected_poweroutlet": 56,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2777,7 +2777,7 @@
     "fields": {
     "fields": {
         "device": 5,
         "device": 5,
         "name": "PSU1",
         "name": "PSU1",
-        "connected_endpoint": 32,
+        "_connected_poweroutlet": 32,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2787,7 +2787,7 @@
     "fields": {
     "fields": {
         "device": 3,
         "device": 3,
         "name": "PSU0",
         "name": "PSU0",
-        "connected_endpoint": 27,
+        "_connected_poweroutlet": 27,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2797,7 +2797,7 @@
     "fields": {
     "fields": {
         "device": 3,
         "device": 3,
         "name": "PSU1",
         "name": "PSU1",
-        "connected_endpoint": 51,
+        "_connected_poweroutlet": 51,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2807,7 +2807,7 @@
     "fields": {
     "fields": {
         "device": 7,
         "device": 7,
         "name": "PEM0",
         "name": "PEM0",
-        "connected_endpoint": 53,
+        "_connected_poweroutlet": 53,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2817,7 +2817,7 @@
     "fields": {
     "fields": {
         "device": 7,
         "device": 7,
         "name": "PEM1",
         "name": "PEM1",
-        "connected_endpoint": 29,
+        "_connected_poweroutlet": 29,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2827,7 +2827,7 @@
     "fields": {
     "fields": {
         "device": 7,
         "device": 7,
         "name": "PEM2",
         "name": "PEM2",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2837,7 +2837,7 @@
     "fields": {
     "fields": {
         "device": 7,
         "device": 7,
         "name": "PEM3",
         "name": "PEM3",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2847,7 +2847,7 @@
     "fields": {
     "fields": {
         "device": 8,
         "device": 8,
         "name": "PEM0",
         "name": "PEM0",
-        "connected_endpoint": 54,
+        "_connected_poweroutlet": 54,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2857,7 +2857,7 @@
     "fields": {
     "fields": {
         "device": 8,
         "device": 8,
         "name": "PEM1",
         "name": "PEM1",
-        "connected_endpoint": 30,
+        "_connected_poweroutlet": 30,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2867,7 +2867,7 @@
     "fields": {
     "fields": {
         "device": 8,
         "device": 8,
         "name": "PEM2",
         "name": "PEM2",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2877,7 +2877,7 @@
     "fields": {
     "fields": {
         "device": 8,
         "device": 8,
         "name": "PEM3",
         "name": "PEM3",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2887,7 +2887,7 @@
     "fields": {
     "fields": {
         "device": 6,
         "device": 6,
         "name": "PSU0",
         "name": "PSU0",
-        "connected_endpoint": 55,
+        "_connected_poweroutlet": 55,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2897,7 +2897,7 @@
     "fields": {
     "fields": {
         "device": 6,
         "device": 6,
         "name": "PSU1",
         "name": "PSU1",
-        "connected_endpoint": 31,
+        "_connected_poweroutlet": 31,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },
@@ -2907,7 +2907,7 @@
     "fields": {
     "fields": {
         "device": 9,
         "device": 9,
         "name": "PSU",
         "name": "PSU",
-        "connected_endpoint": null,
+        "_connected_poweroutlet": null,
         "connection_status": true
         "connection_status": true
     }
     }
 },
 },

+ 560 - 28
netbox/dcim/forms.py

@@ -10,24 +10,24 @@ from mptt.forms import TreeNodeChoiceField
 from taggit.forms import TagField
 from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
 
 
+from circuits.models import Circuit, Provider
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from ipam.models import IPAddress, VLAN, VLANGroup
 from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     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 virtualization.models import Cluster, ClusterGroup
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
     Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
     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+\}'
 DEVICE_BY_PK_RE = r'{\d+\}'
@@ -963,7 +963,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name',
+            'device_type', 'name', 'maximum_draw', 'allocated_draw',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
@@ -977,16 +977,29 @@ class PowerPortTemplateCreateForm(ComponentForm):
 
 
 
 
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
-            'device_type', 'name',
+            'device_type', 'name', 'power_port', 'feed_leg',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             '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):
 class PowerOutletTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
@@ -1947,7 +1960,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'device', 'name', 'description', 'tags',
+            'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1972,6 +1985,10 @@ class PowerPortCreateForm(ComponentForm):
 #
 #
 
 
 class PowerOutletForm(BootstrapMixin, forms.ModelForm):
 class PowerOutletForm(BootstrapMixin, forms.ModelForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1979,12 +1996,20 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'device', 'name', 'description', 'tags',
+            'device', 'name', 'power_port', 'feed_leg', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             '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):
 class PowerOutletCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
@@ -2004,6 +2029,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    feed_leg = forms.ChoiceField(
+        choices=POWERFEED_LEG_CHOICES,
+        required=False,
+    )
     description = forms.CharField(
     description = forms.CharField(
         max_length=100,
         max_length=100,
         required=False
         required=False
@@ -2520,7 +2549,10 @@ class RearPortBulkDisconnectForm(ConfirmationForm):
 # Cables
 # 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(
     termination_b_site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         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(
     termination_b_id = forms.IntegerField(
         label='Name',
         label='Name',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/{{termination_b_type}}s/',
+            api_url='/api/dcim/console-server-ports/',
             disabled_indicator='cable',
             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:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         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):
 class CableForm(BootstrapMixin, forms.ModelForm):
@@ -3155,3 +3344,346 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --',
         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 = [
     dependencies = [
         ('dcim', '0069_deprecate_nullablecharfield'),
         ('dcim', '0069_deprecate_nullablecharfield'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
     ]
 
 
     operations = [
     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.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 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 django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
@@ -1053,6 +1053,18 @@ class PowerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         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()
     objects = DeviceComponentManager()
 
 
@@ -1076,6 +1088,19 @@ class PowerOutletTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         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()
     objects = DeviceComponentManager()
 
 
@@ -1086,6 +1111,14 @@ class PowerOutletTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         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):
 class InterfaceTemplate(ComponentTemplateModel):
     """
     """
@@ -1828,13 +1861,32 @@ class PowerPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         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',
         to='dcim.PowerOutlet',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
         related_name='connected_endpoint',
         related_name='connected_endpoint',
         blank=True,
         blank=True,
         null=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(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
@@ -1843,7 +1895,7 @@ class PowerPort(CableTermination, ComponentModel):
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name', 'description']
+    csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -1859,9 +1911,68 @@ class PowerPort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.maximum_draw,
+            self.allocated_draw,
             self.description,
             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
 # Power outlets
@@ -1879,6 +1990,19 @@ class PowerOutlet(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         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(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
@@ -1887,7 +2011,7 @@ class PowerOutlet(CableTermination, ComponentModel):
     objects = DeviceComponentManager()
     objects = DeviceComponentManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name', 'description']
+    csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description']
 
 
     class Meta:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1902,9 +2026,19 @@ class PowerOutlet(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.power_port.name if self.power_port else None,
+            self.get_feed_leg_display(),
             self.description,
             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
 # Interfaces
@@ -2646,6 +2780,14 @@ class Cable(ChangeLoggedModel):
     def get_status_class(self):
     def get_status_class(self):
         return 'success' if self.status else 'info'
         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):
     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
         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]
         b_endpoint = b_path[-1][2]
 
 
         return a_endpoint, b_endpoint, path_status
         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 (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     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 = """
 REGION_LINK = """
@@ -144,6 +145,10 @@ STATUS_LABEL = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 <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 = """
 DEVICE_PRIMARY_IP = """
 {{ record.primary_ip6.address.ip|default:"" }}
 {{ record.primary_ip6.address.ip|default:"" }}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -786,3 +791,50 @@ class VirtualChassisTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VirtualChassis
         model = VirtualChassis
         fields = ('pk', 'master', 'domain', 'member_count', 'actions')
         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 (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
     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 ipam.models import IPAddress, VLAN
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
@@ -3532,3 +3532,260 @@ class VirtualChassisTest(APITestCase):
             self.assertTrue(
             self.assertTrue(
                 Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
                 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 . import views
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
     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'
 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/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/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'^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+)/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+)/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}),
     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/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/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'^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+)/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+)/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}),
     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/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/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'^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+)/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+)/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}),
     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/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/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'^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+)/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+)/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}),
     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/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/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'^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+)/$', 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+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
     url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
     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/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/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'^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+)/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+)/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}),
     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/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/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'^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+)/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+)/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}),
     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/(?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'),
     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.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Count, F
 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
+from django.utils.http import is_safe_url
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
@@ -30,8 +32,9 @@ from . import filters, forms, tables
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     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()
         prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
 
 
         reservations = RackReservation.objects.filter(rack=rack)
         reservations = RackReservation.objects.filter(rack=rack)
+        power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel')
 
 
         return render(request, 'dcim/rack.html', {
         return render(request, 'dcim/rack.html', {
             'rack': rack,
             'rack': rack,
             'reservations': reservations,
             'reservations': reservations,
+            'power_feeds': power_feeds,
             'nonracked_devices': nonracked_devices,
             'nonracked_devices': nonracked_devices,
             'next_rack': next_rack,
             'next_rack': next_rack,
             'prev_rack': prev_rack,
             'prev_rack': prev_rack,
@@ -910,7 +915,7 @@ class DeviceView(View):
         consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
         consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
 
 
         # Power ports
         # Power ports
-        power_ports = device.powerports.select_related('connected_endpoint__device', 'cable')
+        power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable')
 
 
         # Power outlets
         # Power outlets
         poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable')
         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'
     permission_required = 'dcim.add_cable'
-    model = Cable
-    model_form = forms.CableCreateForm
     template_name = 'dcim/cable_connect.html'
     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):
 class CableEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1760,11 +1824,11 @@ class ConsoleConnectionsListView(ObjectListView):
 
 
 class PowerConnectionsListView(ObjectListView):
 class PowerConnectionsListView(ObjectListView):
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
-        'device', 'connected_endpoint__device'
+        'device', '_connected_poweroutlet__device'
     ).filter(
     ).filter(
-        connected_endpoint__isnull=False
+        _connected_poweroutlet__isnull=False
     ).order_by(
     ).order_by(
-        'cable', 'connected_endpoint__device__name', 'connected_endpoint__name'
+        'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name'
     )
     )
     filter = filters.PowerConnectionFilter
     filter = filters.PowerConnectionFilter
     filter_form = forms.PowerConnectionFilterForm
     filter_form = forms.PowerConnectionFilterForm
@@ -2114,3 +2178,139 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
             'form': form,
             'form': form,
             'return_url': self.get_return_url(request, device),
             '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 = [
     dependencies = [
         ('contenttypes', '0002_remove_content_type_name'),
         ('contenttypes', '0002_remove_content_type_name'),
-        ('extras', '0017_exporttemplate_mime_type_length'),
+        ('extras', '0018_exporttemplate_add_jinja2'),
     ]
     ]
 
 
     operations = [
     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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
         ('circuits', '0015_custom_tag_models'),
         ('circuits', '0015_custom_tag_models'),
         ('dcim', '0070_custom_tag_models'),
         ('dcim', '0070_custom_tag_models'),
         ('ipam', '0025_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):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('extras', '0019_tag_data'),
+        ('extras', '0020_tag_data'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 1 - 1
netbox/extras/models.py

@@ -566,7 +566,7 @@ class TopologyMap(models.Model):
         from dcim.models import PowerPort
         from dcim.models import PowerPort
 
 
         # Add all power connections to the graph
         # 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'
             style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
             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 = [
     dependencies = [
         ('ipam', '0024_vrf_allow_null_rd'),
         ('ipam', '0024_vrf_allow_null_rd'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 1 - 1
netbox/netbox/views.py

@@ -166,7 +166,7 @@ class HomeView(View):
             connected_endpoint__isnull=False
             connected_endpoint__isnull=False
         )
         )
         connected_powerports = PowerPort.objects.filter(
         connected_powerports = PowerPort.objects.filter(
-            connected_endpoint__isnull=False
+            _connected_poweroutlet__isnull=False
         )
         )
         connected_interfaces = Interface.objects.filter(
         connected_interfaces = Interface.objects.filter(
             _connected_interface__isnull=False,
             _connected_interface__isnull=False,

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

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

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

@@ -22,7 +22,7 @@
         </div>
         </div>
     {% endif %}
     {% endif %}
     {% with termination_a=form.instance.termination_a %}
     {% 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="row">
             <div class="col-md-5">
             <div class="col-md-5">
                 <div class="panel panel-default">
                 <div class="panel panel-default">
@@ -101,21 +101,43 @@
                         <strong>B Side</strong>
                         <strong>B Side</strong>
                     </div>
                     </div>
                     <div class="panel-body">
                     <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>
                         </div>
                         </div>
-                        {% render_field form.termination_b_device %}
-                        {% render_field form.termination_b_type %}
                         {% render_field form.termination_b_id %}
                         {% render_field form.termination_b_id %}
                     </div>
                     </div>
                 </div>
                 </div>

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

@@ -332,6 +332,49 @@
                     {% endif %}
                     {% endif %}
                 </div>
                 </div>
             {% endif %}
             {% 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 %}
             {% if request.user.is_authenticated %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
                     <div class="panel-heading">
                     <div class="panel-heading">
@@ -627,9 +670,10 @@
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                     <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                                 {% endif %}
                                 {% endif %}
                                 <th>Name</th>
                                 <th>Name</th>
+                                <th>Input/Leg</th>
                                 <th>Description</th>
                                 <th>Description</th>
                                 <th>Cable</th>
                                 <th>Cable</th>
-                                <th colspan="2">Connection</th>
+                                <th colspan="3">Connection</th>
                                 <th></th>
                                 <th></th>
                             </tr>
                             </tr>
                         </thead>
                         </thead>

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

@@ -4,6 +4,7 @@
     <td>
     <td>
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
         <i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
     </td>
     </td>
+    <td></td>
 
 
     {# Description #}
     {# Description #}
     <td>
     <td>
@@ -38,9 +39,16 @@
         {% if cp.cable %}
         {% if cp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %}
         {% elif perms.dcim.add_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 %}
         {% endif %}
         {% if perms.dcim.change_consoleport %}
         {% if perms.dcim.change_consoleport %}
             <a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" title="Edit port" class="btn btn-info btn-xs">
             <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 %}
         {% if csp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %}
         {% elif perms.dcim.add_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 %}
         {% endif %}
         {% if perms.dcim.change_consoleserverport %}
         {% if perms.dcim.change_consoleserverport %}
             <a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" title="Edit port" class="btn btn-info btn-xs">
             <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 %}
         {% if frontport.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %}
         {% elif perms.dcim.add_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 %}
         {% endif %}
         {% if perms.dcim.change_frontport %}
         {% 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">
             <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 %}
             {% if iface.cable %}
                 {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
                 {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
             {% elif iface.is_connectable and perms.dcim.add_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 %}
             {% 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">
             <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>
                 <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 }}
         <i class="fa fa-fw fa-bolt"></i> {{ po }}
     </td>
     </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 #}
     {# Description #}
     <td>
     <td>
         {{ po.description|placeholder }}
         {{ po.description|placeholder }}
@@ -30,14 +39,23 @@
 
 
     {# Connection #}
     {# Connection #}
     {% if po.connected_endpoint %}
     {% 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 %}
     {% else %}
-        <td colspan="2">
+        <td colspan="3">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
         </td>
         </td>
     {% endif %}
     {% endif %}
@@ -47,7 +65,7 @@
         {% if po.cable %}
         {% if po.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %}
         {% elif perms.dcim.add_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>
                 <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

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

@@ -5,6 +5,15 @@
         <i class="fa fa-fw fa-bolt"></i> {{ pp }}
         <i class="fa fa-fw fa-bolt"></i> {{ pp }}
     </td>
     </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 #}
     {# Description #}
     <td>
     <td>
         {{ pp.description }}
         {{ pp.description }}
@@ -20,13 +29,17 @@
     </td>
     </td>
 
 
     {# Connection #}
     {# Connection #}
-    {% if pp.connected_endpoint %}
+    {% if pp.connected_endpoint.device %}
         <td>
         <td>
             <a href="{% url 'dcim:device' pk=pp.connected_endpoint.device.pk %}">{{ pp.connected_endpoint.device }}</a>
             <a href="{% url 'dcim:device' pk=pp.connected_endpoint.device.pk %}">{{ pp.connected_endpoint.device }}</a>
         </td>
         </td>
         <td>
         <td>
             {{ pp.connected_endpoint }}
             {{ pp.connected_endpoint }}
         </td>
         </td>
+    {% elif pp.connected_endpoint %}
+        <td colspan="2">
+            <a href="{{ pp.connected_endpoint.get_absolute_url }}">{{ pp.connected_endpoint }}</a>
+        </td>
     {% else %}
     {% else %}
         <td colspan="2">
         <td colspan="2">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
@@ -38,9 +51,15 @@
         {% if pp.cable %}
         {% if pp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %}
         {% elif perms.dcim.add_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 %}
         {% endif %}
         {% if perms.dcim.change_powerport %}
         {% if perms.dcim.change_powerport %}
             <a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" title="Edit port" class="btn btn-info btn-xs">
             <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 %}
         {% if rearport.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %}
             {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %}
         {% elif perms.dcim.add_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 %}
         {% endif %}
         {% if perms.dcim.change_rearport %}
         {% 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">
             <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 %}
                 {% endif %}
             </div>
             </div>
         </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>
                     <tr>
-                        <th>Name</th>
-                        <th>Role</th>
+                        <th>Panel</th>
+                        <th>Feed</th>
+                        <th>Status</th>
                         <th>Type</th>
                         <th>Type</th>
-                        <th>Parent</th>
                     </tr>
                     </tr>
-                    {% for device in nonracked_devices %}
-                        <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
+                    {% for powerfeed in power_feeds %}
+                        <tr>
                             <td>
                             <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>
-                            <td>{{ device.device_role }}</td>
-                            <td>{{ device.device_type.display_name }}</td>
                             <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>
                             </td>
                         </tr>
                         </tr>
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </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 panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Images</strong>
                 <strong>Images</strong>
@@ -299,19 +289,62 @@
             {% endif %}
             {% endif %}
         </div>
         </div>
 	</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>
         </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>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -368,6 +368,29 @@
                         </li>
                         </li>
                     </ul>
                     </ul>
                 </li>
                 </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 %}
                 {% if request.user.is_authenticated %}
                     <li class="dropdown{% if request.path|contains:'/secrets/' %} active{% endif %}">
                     <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>
                         <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 = [
     dependencies = [
         ('tenancy', '0005_change_logging'),
         ('tenancy', '0005_change_logging'),
-        ('extras', '0018_tag_taggeditem'),
+        ('extras', '0019_tag_taggeditem'),
     ]
     ]
 
 
     operations = [
     operations = [

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

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