From 36f95f78422d0882e3ad507ca1805063443ee54f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 31 Jul 2023 17:50:48 +0530 Subject: [PATCH] Adds tenant on power feed (#13300) * adds tenant on power feed * cleanup * adds power feed count on tenant object view * Misc cleanup; add filterset tests --------- Co-authored-by: Jeremy Stretch --- netbox/dcim/api/serializers.py | 6 +- netbox/dcim/fields.py | 1 - netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 8 ++- netbox/dcim/forms/bulk_import.py | 8 ++- netbox/dcim/forms/filtersets.py | 3 +- netbox/dcim/forms/model_forms.py | 5 +- .../dcim/migrations/0180_powerfeed_tenant.py | 20 ++++++ netbox/dcim/models/power.py | 9 ++- netbox/dcim/tables/power.py | 11 +-- netbox/dcim/tests/test_filtersets.py | 70 ++++++++++++++++++- netbox/templates/dcim/powerfeed.html | 9 +++ netbox/tenancy/views.py | 3 +- 13 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 netbox/dcim/migrations/0180_powerfeed_tenant.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a611f64d0..550e9123b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1236,6 +1236,10 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect choices=PowerFeedPhaseChoices, default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, ) + tenant = NestedTenantSerializer( + required=False, + allow_null=True + ) class Meta: model = PowerFeed @@ -1243,5 +1247,5 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index cef3283bb..db1a28d39 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -6,7 +6,6 @@ from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded from .lookups import PathContains __all__ = ( - 'ASNField', 'MACAddressField', 'PathField', 'WWNField', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 416c022ce..e575c00db 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1880,7 +1880,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d721cb09b..9ee938859 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -754,6 +754,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=BulkEditNullBooleanSelect ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( max_length=200, required=False @@ -764,10 +768,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): model = PowerFeed fieldsets = ( - (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')), + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant')), ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) ) - nullable_fields = ('location', 'description', 'comments') + nullable_fields = ('location', 'tenant', 'description', 'comments') # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index cd774fd18..d3acbc716 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -1174,6 +1174,12 @@ class PowerFeedImportForm(NetBoxModelImportForm): required=False, help_text=_('Rack') ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text=_('Assigned tenant') + ) status = CSVChoiceField( choices=PowerFeedStatusChoices, help_text=_('Operational status') @@ -1195,7 +1201,7 @@ class PowerFeedImportForm(NetBoxModelImportForm): model = PowerFeed fields = ( 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', - 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', + 'voltage', 'amperage', 'max_utilization', 'tenant', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 06d38627d..f0ac017fa 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -985,11 +985,12 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): tag = TagFilterField(model) -class PowerFeedFilterForm(NetBoxModelFilterSetForm): +class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = PowerFeed fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), ) region_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 632dabb81..4c382babc 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -611,7 +611,7 @@ class PowerPanelForm(NetBoxModelForm): ] -class PowerFeedForm(NetBoxModelForm): +class PowerFeedForm(TenancyForm, NetBoxModelForm): power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), selector=True @@ -626,13 +626,14 @@ class PowerFeedForm(NetBoxModelForm): fieldsets = ( ('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = PowerFeed fields = [ 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'description', 'comments', 'tags', + 'max_utilization', 'tenant_group', 'tenant', 'description', 'comments', 'tags' ] diff --git a/netbox/dcim/migrations/0180_powerfeed_tenant.py b/netbox/dcim/migrations/0180_powerfeed_tenant.py new file mode 100644 index 000000000..af550b21d --- /dev/null +++ b/netbox/dcim/migrations/0180_powerfeed_tenant.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.8 on 2023-07-29 11:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0010_tenant_relax_uniqueness'), + ('dcim', '0179_interfacetemplate_rf_role'), + ] + + operations = [ + migrations.AddField( + model_name='powerfeed', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'), + ), + ] diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 3377a9edb..9b6c08527 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -131,10 +131,17 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): default=0, editable=False ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='power_feeds', + blank=True, + null=True + ) clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', + 'max_utilization', 'tenant', ) prerequisite_models = ( 'dcim.PowerPanel', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 272ea2b7d..e4313da65 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,6 +1,6 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from tenancy.tables import ContactsColumnMixin +from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from netbox.tables import NetBoxTable, columns @@ -51,7 +51,7 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. -class PowerFeedTable(CableTerminationTable): +class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): name = tables.Column( linkify=True ) @@ -69,6 +69,9 @@ class PowerFeedTable(CableTerminationTable): available_power = tables.Column( verbose_name='Available power (VA)' ) + tenant = tables.Column( + linkify=True + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:powerfeed_list' @@ -78,8 +81,8 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', - 'description', 'comments', 'tags', 'created', 'last_updated', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index a1e684cb9..c75df5cf6 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4419,6 +4419,21 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): ) Rack.objects.bulk_create(racks) + tenant_groups = ( + TenantGroup(name='Tenant group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant group 3', slug='tenant-group-3'), + ) + for tenantgroup in tenant_groups: + tenantgroup.save() + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), + ) + Tenant.objects.bulk_create(tenants) + power_panels = ( PowerPanel(name='Power Panel 1', site=sites[0]), PowerPanel(name='Power Panel 2', site=sites[1]), @@ -4427,9 +4442,44 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): PowerPanel.objects.bulk_create(power_panels) power_feeds = ( - PowerFeed(power_panel=power_panels[0], rack=racks[0], name='Power Feed 1', status=PowerFeedStatusChoices.STATUS_ACTIVE, type=PowerFeedTypeChoices.TYPE_PRIMARY, supply=PowerFeedSupplyChoices.SUPPLY_AC, phase=PowerFeedPhaseChoices.PHASE_3PHASE, voltage=100, amperage=100, max_utilization=10), - PowerFeed(power_panel=power_panels[1], rack=racks[1], name='Power Feed 2', status=PowerFeedStatusChoices.STATUS_FAILED, type=PowerFeedTypeChoices.TYPE_PRIMARY, supply=PowerFeedSupplyChoices.SUPPLY_AC, phase=PowerFeedPhaseChoices.PHASE_3PHASE, voltage=200, amperage=200, max_utilization=20), - PowerFeed(power_panel=power_panels[2], rack=racks[2], name='Power Feed 3', status=PowerFeedStatusChoices.STATUS_OFFLINE, type=PowerFeedTypeChoices.TYPE_REDUNDANT, supply=PowerFeedSupplyChoices.SUPPLY_DC, phase=PowerFeedPhaseChoices.PHASE_SINGLE, voltage=300, amperage=300, max_utilization=30), + PowerFeed( + power_panel=power_panels[0], + rack=racks[0], + name='Power Feed 1', + tenant=tenants[0], + status=PowerFeedStatusChoices.STATUS_ACTIVE, + type=PowerFeedTypeChoices.TYPE_PRIMARY, + supply=PowerFeedSupplyChoices.SUPPLY_AC, + phase=PowerFeedPhaseChoices.PHASE_3PHASE, + voltage=100, + amperage=100, + max_utilization=10 + ), + PowerFeed( + power_panel=power_panels[1], + rack=racks[1], + name='Power Feed 2', + tenant=tenants[1], + status=PowerFeedStatusChoices.STATUS_FAILED, + type=PowerFeedTypeChoices.TYPE_PRIMARY, + supply=PowerFeedSupplyChoices.SUPPLY_AC, + phase=PowerFeedPhaseChoices.PHASE_3PHASE, + voltage=200, + amperage=200, + max_utilization=20), + PowerFeed( + power_panel=power_panels[2], + rack=racks[2], + name='Power Feed 3', + tenant=tenants[2], + status=PowerFeedStatusChoices.STATUS_OFFLINE, + type=PowerFeedTypeChoices.TYPE_REDUNDANT, + supply=PowerFeedSupplyChoices.SUPPLY_DC, + phase=PowerFeedPhaseChoices.PHASE_SINGLE, + voltage=300, + amperage=300, + max_utilization=30 + ), ) PowerFeed.objects.bulk_create(power_feeds) @@ -4520,6 +4570,20 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant_group(self): + tenant_groups = TenantGroup.objects.all()[:2] + params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualDeviceContext.objects.all() diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index ecfee3a3c..ce00f333c 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -43,6 +43,15 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Tenant" %} + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + + {% trans "Connected Device" %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 23020e794..3b46b5514 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from circuits.models import Circuit -from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext +from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic from utilities.utils import count_related @@ -145,6 +145,7 @@ class TenantView(generic.ObjectView): (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), # IPAM (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'),