diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 535ffcec1..0c3c141af 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -2,9 +2,8 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from dcim.api.serializers_.sites import SiteSerializer from ipam.choices import * -from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer @@ -45,8 +44,17 @@ class AggregateSerializer(NetBoxModelSerializer): class PrefixSerializer(NetBoxModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - site = SiteSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=PREFIX_SCOPE_TYPES + ), + allow_null=True, + required=False, + default=None + ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) + scope = serializers.SerializerMethodField(read_only=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) vlan = VLANSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=PrefixStatusChoices, required=False) @@ -58,12 +66,20 @@ class PrefixSerializer(NetBoxModelSerializer): class Meta: model = Prefix fields = [ - 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', - 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'children', '_depth', + 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope', + 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'children', '_depth', ] brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope) + context = {'request': self.context['request']} + return serializer(obj.scope, nested=True, context=context).data + class PrefixLengthSerializer(serializers.Serializer): diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 6dffd3287..c07b8441f 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -23,6 +23,11 @@ VRF_RD_MAX_LENGTH = 21 PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MAX = 127 # IPv6 +# models values for ContentTypes which may be Prefix scope types +PREFIX_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', +) + # # IPAddresses diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 894219c64..d7273e3d4 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -332,42 +332,42 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('VRF (RD)'), ) - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - label=_('Region (ID)'), - ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - to_field_name='slug', - label=_('Region (slug)'), - ) - site_group_id = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - to_field_name='slug', - label=_('Site group (slug)'), - ) - 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)'), - ) + # region_id = TreeNodeMultipleChoiceFilter( + # queryset=Region.objects.all(), + # field_name='site__region', + # lookup_expr='in', + # label=_('Region (ID)'), + # ) + # region = TreeNodeMultipleChoiceFilter( + # queryset=Region.objects.all(), + # field_name='site__region', + # lookup_expr='in', + # to_field_name='slug', + # label=_('Region (slug)'), + # ) + # site_group_id = TreeNodeMultipleChoiceFilter( + # queryset=SiteGroup.objects.all(), + # field_name='site__group', + # lookup_expr='in', + # label=_('Site group (ID)'), + # ) + # site_group = TreeNodeMultipleChoiceFilter( + # queryset=SiteGroup.objects.all(), + # field_name='site__group', + # lookup_expr='in', + # to_field_name='slug', + # label=_('Site group (slug)'), + # ) + # 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)'), + # ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), label=_('VLAN (ID)'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 79debd0ed..9733647b7 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -204,25 +204,25 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(NetBoxModelBulkEditForm): - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False - ) - site_group = DynamicModelChoiceField( - label=_('Site group'), - queryset=SiteGroup.objects.all(), - required=False - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } - ) + # region = DynamicModelChoiceField( + # label=_('Region'), + # queryset=Region.objects.all(), + # required=False + # ) + # site_group = DynamicModelChoiceField( + # label=_('Site group'), + # queryset=SiteGroup.objects.all(), + # required=False + # ) + # site = DynamicModelChoiceField( + # label=_('Site'), + # queryset=Site.objects.all(), + # required=False, + # query_params={ + # 'region_id': '$region', + # 'group_id': '$site_group', + # } + # ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -282,12 +282,12 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): model = Prefix fieldsets = ( FieldSet('tenant', 'status', 'role', 'description'), - FieldSet('region', 'site_group', 'site', name=_('Site')), + # FieldSet('region', 'site_group', 'site', name=_('Site')), FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')), ) nullable_fields = ( - 'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments', + 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments', ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index dea250c79..b52c36104 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -167,13 +167,13 @@ class PrefixImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) - site = CSVModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - to_field_name='name', - help_text=_('Assigned site') - ) + # site = CSVModelChoiceField( + # label=_('Site'), + # queryset=Site.objects.all(), + # required=False, + # to_field_name='name', + # help_text=_('Assigned site') + # ) vlan_group = CSVModelChoiceField( label=_('VLAN group'), queryset=VLANGroup.objects.all(), @@ -204,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm): class Meta: model = Prefix fields = ( - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', + 'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', ) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a32694321..10dbb0bcd 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -170,7 +170,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ), FieldSet('vlan_id', name=_('VLAN Assignment')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + # FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) mask_length__lte = forms.IntegerField( @@ -211,25 +211,25 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=PrefixStatusChoices, required=False ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region') - ) - site_group_id = DynamicModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - required=False, - label=_('Site group') - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, - label=_('Site') - ) + # region_id = DynamicModelMultipleChoiceField( + # queryset=Region.objects.all(), + # required=False, + # label=_('Region') + # ) + # site_group_id = DynamicModelMultipleChoiceField( + # queryset=SiteGroup.objects.all(), + # required=False, + # label=_('Site group') + # ) + # site_id = DynamicModelMultipleChoiceField( + # queryset=Site.objects.all(), + # required=False, + # null_option='None', + # query_params={ + # 'region_id': '$region_id' + # }, + # label=_('Site') + # ) role_id = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), required=False, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 156e7c435..69812c94c 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -201,12 +201,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm): required=False, label=_('VRF') ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), + widget=HTMXSelect(), required=False, - selector=True, - null_option='None' + label=_('Scope type') + ) + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -228,17 +234,48 @@ class PrefixForm(TenancyForm, NetBoxModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('site', 'vlan', name=_('Site/VLAN Assignment')), + FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', - 'description', 'comments', 'tags', + 'prefix', 'vrf', 'vlan', 'scope_type', 'scope', 'status', 'role', 'is_pool', 'mark_utilized', + 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + + if instance is not None and instance.scope: + initial['scope'] = instance.scope + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + if self.instance and scope_type_id != self.instance.scope_type_id: + self.initial['scope'] = None + + def clean(self): + super().clean() + + # Assign the selected scope (if any) + self.instance.scope = self.cleaned_data.get('scope') + class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py new file mode 100644 index 000000000..e6516d8ed --- /dev/null +++ b/netbox/ipam/migrations/0071_prefix_scope.py @@ -0,0 +1,55 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_site_assignments(apps, schema_editor): + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Prefix = apps.get_model('ipam', 'Prefix') + + Prefix.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=models.F('site_id') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0070_vlangroup_vlan_id_ranges'), + ] + + operations = [ + # Add the scope GenericForeignKey + migrations.AddField( + model_name='prefix', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='scope_type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='contenttypes.contenttype' + ), + ), + + # Copy over existing site assignments + migrations.RunPython( + code=copy_site_assignments, + reverse_code=migrations.RunPython.noop + ), + + # Delete the site ForeignKey + migrations.RemoveField( + model_name='prefix', + name='site', + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a540b5810..303706b6f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -199,21 +199,29 @@ class Role(OrganizationalModel): class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): """ - A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and - VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be - assigned to a VLAN where appropriate. + A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain + areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. + A Prefix can also be assigned to a VLAN where appropriate. """ prefix = IPNetworkField( verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') ) - site = models.ForeignKey( - to='dcim.Site', + scope_type = models.ForeignKey( + to='contenttypes.ContentType', on_delete=models.PROTECT, - related_name='prefixes', + related_name='+', blank=True, null=True ) + scope_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + scope = GenericForeignKey( + ct_field='scope_type', + fk_field='scope_id' + ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.PROTECT, @@ -275,7 +283,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): objects = PrefixQuerySet.as_manager() clone_fields = ( - 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', + 'scope_type', 'scope_id', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', ) class Meta: diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 8ec7a5967..399641422 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -241,8 +241,11 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): template_code=VRF_LINK, verbose_name=_('VRF') ) - site = tables.Column( - verbose_name=_('Site'), + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), linkify=True ) vlan_group = tables.Column( @@ -285,11 +288,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): model = Prefix fields = ( 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', - 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', + 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', + 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role', + 'description', ) row_attrs = { 'class': lambda record: 'success' if not record.pk else '', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 67d56f15e..1531667ef 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -492,7 +492,7 @@ class PrefixView(generic.ObjectView): ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( - 'site', 'role', 'tenant', 'vlan', + 'scope', 'role', 'tenant', 'vlan', ) parent_prefix_table = tables.PrefixTable( list(parent_prefixes), @@ -506,7 +506,7 @@ class PrefixView(generic.ObjectView): ).exclude( pk=instance.pk ).prefetch_related( - 'site', 'role', 'tenant', 'vlan', + 'scope', 'role', 'tenant', 'vlan', ) duplicate_prefix_table = tables.PrefixTable( list(duplicate_prefixes), @@ -538,7 +538,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( - 'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group' + 'scope', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group' ) def prep_table_data(self, request, queryset, parent): diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 94307cddb..af69f689d 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -44,17 +44,9 @@ {% endif %} - {% if object.site.region %} -