From 4dae781be0a8f6aae995681cb93f25550b192379 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 9 Mar 2021 14:13:50 -0500 Subject: [PATCH] Change VLANGroup site to scope (GFK) --- netbox/ipam/api/serializers.py | 20 ++++- netbox/ipam/api/views.py | 2 +- netbox/ipam/filters.py | 55 +++++-------- netbox/ipam/forms.py | 77 ++++++++++++++++--- .../ipam/migrations/0045_vlangroup_scope.py | 36 +++++++++ netbox/ipam/models/vlans.py | 32 +++++--- netbox/ipam/tables.py | 6 +- 7 files changed, 169 insertions(+), 59 deletions(-) create mode 100644 netbox/ipam/migrations/0045_vlangroup_scope.py diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 002ad3b89..aa0255e30 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -114,14 +114,20 @@ class RoleSerializer(OrganizationalModelSerializer): class VLANGroupSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - site = NestedSiteSerializer(required=False, allow_null=True) + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + app_label='dcim', + model__in=['region', 'sitegroup', 'site', 'location', 'rack'] + ) + ) + scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup fields = [ - 'id', 'url', 'name', 'slug', 'site', 'description', 'custom_fields', 'created', 'last_updated', - 'vlan_count', + 'id', 'url', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', 'created', + 'last_updated', 'vlan_count', ] validators = [] @@ -138,6 +144,14 @@ class VLANGroupSerializer(OrganizationalModelSerializer): return data + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope, prefix='Nested') + context = {'request': self.context['request']} + + return serializer(obj.scope, context=context).data + class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index b6f0a7463..1e1177772 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -283,7 +283,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class VLANGroupViewSet(CustomFieldModelViewSet): - queryset = VLANGroup.objects.prefetch_related('site').annotate( + queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') ) serializer_class = serializers.VLANGroupSerializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 5ca487065..1dff03144 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from netaddr.core import AddrFormatError @@ -8,8 +9,8 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter, - TreeNodeMultipleChoiceFilter, + BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, + NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface from .choices import * @@ -535,46 +536,32 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - label='Region (ID)', + scope_type = ContentTypeFilter() + region = django_filters.NumberFilter( + method='filter_scope' ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - to_field_name='slug', - label='Region (slug)', + sitegroup = django_filters.NumberFilter( + method='filter_scope' ) - site_group_id = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - label='Site group (ID)', + site = django_filters.NumberFilter( + method='filter_scope' ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - to_field_name='slug', - label='Site group (slug)', + location = django_filters.NumberFilter( + method='filter_scope' ) - 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 (slug)', + rack = django_filters.NumberFilter( + method='filter_scope' ) class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'description', 'scope_id'] + + def filter_scope(self, queryset, name, value): + return queryset.filter( + scope_type=ContentType.objects.get(app_label='dcim', model=name), + scope_id=value + ) class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ab25833ad..4099a6027 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,7 +1,8 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from dcim.models import Device, Interface, Rack, Region, Site, SiteGroup +from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -1126,18 +1127,70 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, + initial_params={ + 'locations': '$location' + }, query_params={ 'region_id': '$region', 'group_id': '$site_group', } ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + initial_params={ + 'racks': '$rack' + }, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) slug = SlugField() class Meta: model = VLANGroup fields = [ - 'region', 'site', 'name', 'slug', 'description', + 'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', ] + fieldsets = ( + ('VLAN Group', ('name', 'slug', 'description')), + ('Scope', ('region', 'site_group', 'site', 'location', 'rack')), + ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + + if instance is not None and instance.scope: + if type(instance.scope) is Rack: + initial['rack'] = instance.scope + elif type(instance.scope) is Location: + initial['location'] = instance.scope + elif type(instance.scope) is Site: + initial['site'] = instance.scope + elif type(instance.scope) is SiteGroup: + initial['site_group'] = instance.scope + elif type(instance.scope) is Region: + initial['region'] = instance.scope + + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Assign scope object + self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or self.cleaned_data['site'] \ + or self.cleaned_data['site_group'] or self.cleaned_data['region'] or None class VLANGroupCSVForm(CustomFieldModelCSVForm): @@ -1155,25 +1208,31 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - region_id = DynamicModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, label=_('Region') ) - site_group_id = DynamicModelMultipleChoiceField( + sitegroup = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, label=_('Site group') ) - site_id = DynamicModelMultipleChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, label=_('Site') ) + location = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) + rack = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack') + ) # diff --git a/netbox/ipam/migrations/0045_vlangroup_scope.py b/netbox/ipam/migrations/0045_vlangroup_scope.py new file mode 100644 index 000000000..0b658219b --- /dev/null +++ b/netbox/ipam/migrations/0045_vlangroup_scope.py @@ -0,0 +1,36 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0044_standardize_models'), + ] + + operations = [ + migrations.RenameField( + model_name='vlangroup', + old_name='site', + new_name='scope_id', + ), + migrations.AlterField( + model_name='vlangroup', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='scope_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ['region', 'sitegroup', 'site', 'location', 'rack'])), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + migrations.AlterModelOptions( + name='vlangroup', + options={'ordering': ('name', 'pk'), 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'}, + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together={('scope_type', 'scope_id', 'name'), ('scope_type', 'scope_id', 'slug')}, + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 131212564..3cf177703 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,3 +1,5 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -31,13 +33,24 @@ class VLANGroup(OrganizationalModel): slug = models.SlugField( max_length=100 ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlan_groups', + scope_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to=Q( + app_label='dcim', + model__in=['region', 'sitegroup', 'site', 'location', 'rack'] + ), blank=True, null=True ) + scope_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + scope = GenericForeignKey( + ct_field='scope_type', + fk_field='scope_id' + ) description = models.CharField( max_length=200, blank=True @@ -45,13 +58,13 @@ class VLANGroup(OrganizationalModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['name', 'slug', 'site', 'description'] + csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description'] class Meta: - ordering = ('site', 'name', 'pk') # (site, name) may be non-unique + ordering = ('name', 'pk') # Name may be non-unique unique_together = [ - ['site', 'name'], - ['site', 'slug'], + ['scope_type', 'scope_id', 'name'], + ['scope_type', 'scope_id', 'slug'], ] verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' @@ -66,7 +79,8 @@ class VLANGroup(OrganizationalModel): return ( self.name, self.slug, - self.site.name if self.site else None, + f'{self.scope_type.app_label}.{self.scope_type.model}', + self.scope_id, self.description, ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 6553480c6..b8b166cdc 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -414,7 +414,7 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) - site = tables.Column( + scope = tables.Column( linkify=True ) vlan_count = LinkedCountColumn( @@ -429,8 +429,8 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions') - default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions') + fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') #