Преглед изворни кода

Closes #6414: Enable assigning prefixes to various object types (#17692)

* Replace site FK on Prefix with scope GFK

* Add denormalized relations

* Update prefix filters

* Add generic relations for Prefix

* Update GraphQL type for Prefix model

* Fix tests; misc cleanup

* Remove prefix_count from SiteSerializer

* Remove site field from PrefixBulkEditForm

* Restore scope filters for prefixes

* Fix scope population on PrefixForm init

* Show scope type

* Assign scope during bulk import of prefixes

* Correct handling of GenericForeignKey in PrefixForm

* Add prefix counts to all scoped objects

* Fix migration; linter fix

* Add limit_choices_to on scope_type

* Clean up cache_related_objects()

* Enable bulk editing prefix scope
Jeremy Stretch пре 1 година
родитељ
комит
75270c1aef

+ 8 - 4
netbox/dcim/api/serializers_/sites.py

@@ -21,12 +21,13 @@ __all__ = (
 class RegionSerializer(NestedGroupModelSerializer):
 class RegionSerializer(NestedGroupModelSerializer):
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True, default=0)
     site_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'site_count', '_depth',
+            'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
@@ -34,12 +35,13 @@ class RegionSerializer(NestedGroupModelSerializer):
 class SiteGroupSerializer(NestedGroupModelSerializer):
 class SiteGroupSerializer(NestedGroupModelSerializer):
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True, default=0)
     site_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
 
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
-            'created', 'last_updated', 'site_count', '_depth',
+            'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
@@ -61,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer):
     # Related object counts
     # Related object counts
     circuit_count = RelatedObjectCountField('circuit_terminations')
     circuit_count = RelatedObjectCountField('circuit_terminations')
     device_count = RelatedObjectCountField('devices')
     device_count = RelatedObjectCountField('devices')
-    prefix_count = RelatedObjectCountField('prefixes')
+    prefix_count = RelatedObjectCountField('_prefixes')
     rack_count = RelatedObjectCountField('racks')
     rack_count = RelatedObjectCountField('racks')
     vlan_count = RelatedObjectCountField('vlans')
     vlan_count = RelatedObjectCountField('vlans')
     virtualmachine_count = RelatedObjectCountField('virtual_machines')
     virtualmachine_count = RelatedObjectCountField('virtual_machines')
@@ -84,11 +86,13 @@ class LocationSerializer(NestedGroupModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True, default=0)
     rack_count = serializers.IntegerField(read_only=True, default=0)
     device_count = serializers.IntegerField(read_only=True, default=0)
     device_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
 
     class Meta:
     class Meta:
         model = Location
         model = Location
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
             'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
-            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
+            'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
+            'prefix_count', '_depth',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
         brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')

+ 24 - 0
netbox/dcim/models/sites.py

@@ -28,6 +28,12 @@ class Region(ContactsMixin, NestedGroupModel):
     states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
     states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
     also considered to be members of its parent and ancestor region(s).
     also considered to be members of its parent and ancestor region(s).
     """
     """
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='region'
+    )
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
@@ -78,6 +84,12 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
     within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
     nested recursively to form a hierarchy.
     nested recursively to form a hierarchy.
     """
     """
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='site_group'
+    )
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
@@ -214,6 +226,12 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     )
     )
 
 
     # Generic relations
     # Generic relations
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='site'
+    )
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',
@@ -273,6 +291,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
     )
     )
 
 
     # Generic relations
     # Generic relations
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='location'
+    )
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         to='ipam.VLANGroup',
         content_type_field='scope_type',
         content_type_field='scope_type',

+ 22 - 6
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 drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from dcim.api.serializers_.sites import SiteSerializer
 from ipam.choices import *
 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 ipam.models import Aggregate, IPAddress, IPRange, Prefix
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.api.serializers import NetBoxModelSerializer
@@ -45,8 +44,17 @@ class AggregateSerializer(NetBoxModelSerializer):
 
 
 class PrefixSerializer(NetBoxModelSerializer):
 class PrefixSerializer(NetBoxModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     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)
     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)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     vlan = VLANSerializer(nested=True, required=False, allow_null=True)
     vlan = VLANSerializer(nested=True, required=False, allow_null=True)
     status = ChoiceField(choices=PrefixStatusChoices, required=False)
     status = ChoiceField(choices=PrefixStatusChoices, required=False)
@@ -58,12 +66,20 @@ class PrefixSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         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')
         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):
 class PrefixLengthSerializer(serializers.Serializer):
 
 

+ 12 - 0
netbox/ipam/apps.py

@@ -1,5 +1,7 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
 
 
+from netbox import denormalized
+
 
 
 class IPAMConfig(AppConfig):
 class IPAMConfig(AppConfig):
     name = "ipam"
     name = "ipam"
@@ -8,6 +10,16 @@ class IPAMConfig(AppConfig):
     def ready(self):
     def ready(self):
         from netbox.models.features import register_models
         from netbox.models.features import register_models
         from . import signals, search  # noqa: F401
         from . import signals, search  # noqa: F401
+        from .models import Prefix
 
 
         # Register models
         # Register models
         register_models(*self.get_models())
         register_models(*self.get_models())
+
+        # Register denormalized fields
+        denormalized.register(Prefix, '_site', {
+            '_region': 'region',
+            '_sitegroup': 'group',
+        })
+        denormalized.register(Prefix, '_location', {
+            '_site': 'site',
+        })

+ 5 - 0
netbox/ipam/constants.py

@@ -23,6 +23,11 @@ VRF_RD_MAX_LENGTH = 21
 PREFIX_LENGTH_MIN = 1
 PREFIX_LENGTH_MIN = 1
 PREFIX_LENGTH_MAX = 127  # IPv6
 PREFIX_LENGTH_MAX = 127  # IPv6
 
 
+# models values for ContentTypes which may be Prefix scope types
+PREFIX_SCOPE_TYPES = (
+    'region', 'sitegroup', 'site', 'location',
+)
+
 
 
 #
 #
 # IPAddresses
 # IPAddresses

+ 22 - 7
netbox/ipam/filtersets.py

@@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema_field
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
 from circuits.models import Provider
 from circuits.models import Provider
-from dcim.models import Device, Interface, Region, Site, SiteGroup
+from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
@@ -332,42 +332,57 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='rd',
         to_field_name='rd',
         label=_('VRF (RD)'),
         label=_('VRF (RD)'),
     )
     )
+    scope_type = ContentTypeFilter()
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region',
+        field_name='_region',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Region (ID)'),
         label=_('Region (ID)'),
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='site__region',
+        field_name='_region',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Region (slug)'),
         label=_('Region (slug)'),
     )
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='site__group',
+        field_name='_sitegroup',
         lookup_expr='in',
         lookup_expr='in',
         label=_('Site group (ID)'),
         label=_('Site group (ID)'),
     )
     )
     site_group = TreeNodeMultipleChoiceFilter(
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='site__group',
+        field_name='_sitegroup',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site group (slug)'),
         label=_('Site group (slug)'),
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
+        field_name='_site',
         label=_('Site (ID)'),
         label=_('Site (ID)'),
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label=_('Site (slug)'),
         label=_('Site (slug)'),
     )
     )
+    location_id = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='_location',
+        lookup_expr='in',
+        label=_('Location (ID)'),
+    )
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='_location',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Location (slug)'),
+    )
     vlan_id = django_filters.ModelMultipleChoiceFilter(
     vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),
         label=_('VLAN (ID)'),
@@ -393,7 +408,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ('id', 'is_pool', 'mark_utilized', 'description')
+        fields = ('id', 'scope_id', 'is_pool', 'mark_utilized', 'description')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 26 - 18
netbox/ipam/forms/bulk_edit.py

@@ -204,24 +204,18 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
 
 
 
 
 class PrefixBulkEditForm(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
+    scope_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
+        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
+        required=False,
+        label=_('Scope type')
     )
     )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
+    scope = DynamicModelChoiceField(
+        label=_('Scope'),
+        queryset=Site.objects.none(),  # Initial queryset
         required=False,
         required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
+        disabled=True,
+        selector=True
     )
     )
     vlan_group = DynamicModelChoiceField(
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
@@ -282,14 +276,28 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
     model = Prefix
     model = Prefix
     fieldsets = (
     fieldsets = (
         FieldSet('tenant', 'status', 'role', 'description'),
         FieldSet('tenant', 'status', 'role', 'description'),
-        FieldSet('region', 'site_group', 'site', name=_('Site')),
         FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
         FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
+        FieldSet('scope_type', 'scope', name=_('Scope')),
         FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
         FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments',
+        'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
     )
     )
 
 
+    def __init__(self, *args, **kwargs):
+        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
+
 
 
 class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
 class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(

+ 8 - 7
netbox/ipam/forms/bulk_import.py

@@ -167,12 +167,10 @@ class PrefixImportForm(NetBoxModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Assigned tenant')
         help_text=_('Assigned tenant')
     )
     )
-    site = CSVModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
+    scope_type = CSVContentTypeField(
+        queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
         required=False,
         required=False,
-        to_field_name='name',
-        help_text=_('Assigned site')
+        label=_('Scope type (app & model)')
     )
     )
     vlan_group = CSVModelChoiceField(
     vlan_group = CSVModelChoiceField(
         label=_('VLAN group'),
         label=_('VLAN group'),
@@ -204,9 +202,12 @@ class PrefixImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = (
         fields = (
-            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
-            'description', 'comments', 'tags',
+            'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
+            'mark_utilized', 'description', 'comments', 'tags',
         )
         )
+        labels = {
+            'scope_id': 'Scope ID',
+        }
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)

+ 6 - 5
netbox/ipam/forms/filtersets.py

@@ -170,7 +170,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         ),
         ),
         FieldSet('vlan_id', name=_('VLAN Assignment')),
         FieldSet('vlan_id', name=_('VLAN Assignment')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         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', 'location_id', name=_('Scope')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
     )
     )
     mask_length__lte = forms.IntegerField(
     mask_length__lte = forms.IntegerField(
@@ -224,12 +224,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     site_id = DynamicModelMultipleChoiceField(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id'
-        },
         label=_('Site')
         label=_('Site')
     )
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
     role_id = DynamicModelMultipleChoiceField(
     role_id = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,

+ 45 - 8
netbox/ipam/forms/model_forms.py

@@ -201,12 +201,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
         required=False,
         required=False,
         label=_('VRF')
         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,
         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(
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
@@ -228,17 +234,48 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
         FieldSet(
         FieldSet(
             'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')
             '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')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         fields = [
-            'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant',
-            'description', 'comments', 'tags',
+            'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', '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):
 class IPRangeForm(TenancyForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(

+ 10 - 2
netbox/ipam/graphql/types.py

@@ -152,17 +152,25 @@ class IPRangeType(NetBoxObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.Prefix,
     models.Prefix,
-    fields='__all__',
+    exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'),
     filters=PrefixFilter
     filters=PrefixFilter
 )
 )
 class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
 class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
     prefix: str
     prefix: str
-    site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
     role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
 
 
+    @strawberry_django.field
+    def scope(self) -> Annotated[Union[
+        Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+    ], strawberry.union("PrefixScopeType")] | None:
+        return self.scope
+
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.RIR,
     models.RIR,

+ 51 - 0
netbox/ipam/migrations/0071_prefix_scope.py

@@ -0,0 +1,51 @@
+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')
+    Site = apps.get_model('dcim', 'Site')
+
+    Prefix.objects.filter(site__isnull=False).update(
+        scope_type=ContentType.objects.get_for_model(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,
+                limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
+                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
+        ),
+    ]

+ 61 - 0
netbox/ipam/migrations/0072_prefix_cached_relations.py

@@ -0,0 +1,61 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def populate_denormalized_fields(apps, schema_editor):
+    """
+    Copy site ForeignKey values to the scope GFK.
+    """
+    Prefix = apps.get_model('ipam', 'Prefix')
+
+    prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
+    for prefix in prefixes:
+        prefix._region_id = prefix.site.region_id
+        prefix._sitegroup_id = prefix.site.group_id
+        prefix._site_id = prefix.site_id
+        # Note: Location cannot be set prior to migration
+
+    Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0193_poweroutlet_color'),
+        ('ipam', '0071_prefix_scope'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='prefix',
+            name='_location',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='_region',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='_site',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'),
+        ),
+        migrations.AddField(
+            model_name='prefix',
+            name='_sitegroup',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'),
+        ),
+
+        # Populate denormalized FK values
+        migrations.RunPython(
+            code=populate_denormalized_fields,
+            reverse_code=migrations.RunPython.noop
+        ),
+
+        # Delete the site ForeignKey
+        migrations.RemoveField(
+            model_name='prefix',
+            name='site',
+        ),
+    ]

+ 69 - 7
netbox/ipam/models/ip.py

@@ -1,4 +1,5 @@
 import netaddr
 import netaddr
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
@@ -199,21 +200,30 @@ class Role(OrganizationalModel):
 
 
 class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
 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(
     prefix = IPNetworkField(
         verbose_name=_('prefix'),
         verbose_name=_('prefix'),
         help_text=_('IPv4 or IPv6 network with mask')
         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,
         on_delete=models.PROTECT,
-        related_name='prefixes',
+        limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES),
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    scope_id = models.PositiveBigIntegerField(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    scope = GenericForeignKey(
+        ct_field='scope_type',
+        fk_field='scope_id'
+    )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
         to='ipam.VRF',
         to='ipam.VRF',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -262,6 +272,36 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
         help_text=_("Treat as fully utilized")
         help_text=_("Treat as fully utilized")
     )
     )
 
 
+    # Cached associations to enable efficient filtering
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.CASCADE,
+        related_name='_prefixes',
+        blank=True,
+        null=True
+    )
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='_prefixes',
+        blank=True,
+        null=True
+    )
+    _region = models.ForeignKey(
+        to='dcim.Region',
+        on_delete=models.CASCADE,
+        related_name='_prefixes',
+        blank=True,
+        null=True
+    )
+    _sitegroup = models.ForeignKey(
+        to='dcim.SiteGroup',
+        on_delete=models.CASCADE,
+        related_name='_prefixes',
+        blank=True,
+        null=True
+    )
+
     # Cached depth & child counts
     # Cached depth & child counts
     _depth = models.PositiveSmallIntegerField(
     _depth = models.PositiveSmallIntegerField(
         default=0,
         default=0,
@@ -275,7 +315,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
 
 
     clone_fields = (
     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:
     class Meta:
@@ -323,8 +363,30 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
             # Clear host bits from prefix
             # Clear host bits from prefix
             self.prefix = self.prefix.cidr
             self.prefix = self.prefix.cidr
 
 
+        # Cache objects associated with the terminating object (for filtering)
+        self.cache_related_objects()
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
+    def cache_related_objects(self):
+        self._region = self._sitegroup = self._site = self._location = None
+        if self.scope_type:
+            scope_type = self.scope_type.model_class()
+            if scope_type == apps.get_model('dcim', 'region'):
+                self._region = self.scope
+            elif scope_type == apps.get_model('dcim', 'sitegroup'):
+                self._sitegroup = self.scope
+            elif scope_type == apps.get_model('dcim', 'site'):
+                self._region = self.scope.region
+                self._sitegroup = self.scope.group
+                self._site = self.scope
+            elif scope_type == apps.get_model('dcim', 'location'):
+                self._region = self.scope.site.region
+                self._sitegroup = self.scope.site.group
+                self._site = self.scope.site
+                self._location = self.scope
+    cache_related_objects.alters_data = True
+
     @property
     @property
     def family(self):
     def family(self):
         return self.prefix.version if self.prefix else None
         return self.prefix.version if self.prefix else None

+ 9 - 5
netbox/ipam/tables/ip.py

@@ -241,8 +241,11 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
         template_code=VRF_LINK,
         template_code=VRF_LINK,
         verbose_name=_('VRF')
         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
         linkify=True
     )
     )
     vlan_group = tables.Column(
     vlan_group = tables.Column(
@@ -285,11 +288,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
         model = Prefix
         model = Prefix
         fields = (
         fields = (
             'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
             '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 = (
         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 = {
         row_attrs = {
             'class': lambda record: 'success' if not record.pk else '',
             'class': lambda record: 'success' if not record.pk else '',

+ 8 - 8
netbox/ipam/tests/test_filtersets.py

@@ -656,14 +656,14 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         prefixes = (
         prefixes = (
-            Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
-            Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
-            Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
-            Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
-            Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
-            Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
-            Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
-            Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
+            Prefix(prefix='10.0.0.0/24', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
+            Prefix(prefix='10.0.1.0/24', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
+            Prefix(prefix='10.0.2.0/24', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
+            Prefix(prefix='10.0.3.0/24', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
+            Prefix(prefix='2001:db8::/64', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
+            Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
+            Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
+            Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
             Prefix(prefix='10.0.0.0/16'),
             Prefix(prefix='10.0.0.0/16'),
             Prefix(prefix='2001:db8::/32'),
             Prefix(prefix='2001:db8::/32'),
         )
         )

+ 22 - 16
netbox/ipam/tests/test_views.py

@@ -1,5 +1,6 @@
 import datetime
 import datetime
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
@@ -409,9 +410,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         Role.objects.bulk_create(roles)
         Role.objects.bulk_create(roles)
 
 
         prefixes = (
         prefixes = (
-            Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
-            Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
-            Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
+            Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
+            Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
+            Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
         )
         )
         Prefix.objects.bulk_create(prefixes)
         Prefix.objects.bulk_create(prefixes)
 
 
@@ -419,7 +420,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         cls.form_data = {
         cls.form_data = {
             'prefix': IPNetwork('192.0.2.0/24'),
             'prefix': IPNetwork('192.0.2.0/24'),
-            'site': sites[1].pk,
+            'scope_type': ContentType.objects.get_for_model(Site).pk,
+            'scope': sites[1].pk,
             'vrf': vrfs[1].pk,
             'vrf': vrfs[1].pk,
             'tenant': None,
             'tenant': None,
             'vlan': None,
             'vlan': None,
@@ -430,11 +432,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
+        site = sites[0].pk
         cls.csv_data = (
         cls.csv_data = (
-            "vrf,prefix,status",
-            "VRF 1,10.4.0.0/16,active",
-            "VRF 1,10.5.0.0/16,active",
-            "VRF 1,10.6.0.0/16,active",
+            "vrf,prefix,status,scope_type,scope_id",
+            f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
+            f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
+            f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (
@@ -445,7 +448,6 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
-            'site': sites[1].pk,
             'vrf': vrfs[1].pk,
             'vrf': vrfs[1].pk,
             'tenant': None,
             'tenant': None,
             'status': PrefixStatusChoices.STATUS_RESERVED,
             'status': PrefixStatusChoices.STATUS_RESERVED,
@@ -501,11 +503,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         """
         """
         Custom import test for YAML-based imports (versus CSV)
         Custom import test for YAML-based imports (versus CSV)
         """
         """
-        IMPORT_DATA = """
+        site = Site.objects.get(name='Site 1')
+        IMPORT_DATA = f"""
 prefix: 10.1.1.0/24
 prefix: 10.1.1.0/24
 status: active
 status: active
 vlan: 101
 vlan: 101
-site: Site 1
+scope_type: dcim.site
+scope_id: {site.pk}
 """
 """
         # Note, a site is not tied to the VLAN to verify the fix for #12622
         # Note, a site is not tied to the VLAN to verify the fix for #12622
         VLAN.objects.create(vid=101, name='VLAN101')
         VLAN.objects.create(vid=101, name='VLAN101')
@@ -523,19 +527,21 @@ site: Site 1
         prefix = Prefix.objects.get(prefix='10.1.1.0/24')
         prefix = Prefix.objects.get(prefix='10.1.1.0/24')
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.vlan.vid, 101)
         self.assertEqual(prefix.vlan.vid, 101)
-        self.assertEqual(prefix.site.name, "Site 1")
+        self.assertEqual(prefix.scope, site)
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_prefix_import_with_vlan_group(self):
     def test_prefix_import_with_vlan_group(self):
         """
         """
         This test covers a unique import edge case where VLAN group is specified during the import.
         This test covers a unique import edge case where VLAN group is specified during the import.
         """
         """
-        IMPORT_DATA = """
+        site = Site.objects.get(name='Site 1')
+        IMPORT_DATA = f"""
 prefix: 10.1.2.0/24
 prefix: 10.1.2.0/24
 status: active
 status: active
-vlan: 102
-site: Site 1
+scope_type: dcim.site
+scope_id: {site.pk}
 vlan_group: Group 1
 vlan_group: Group 1
+vlan: 102
 """
 """
         vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
         vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
         VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
         VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
@@ -553,7 +559,7 @@ vlan_group: Group 1
         prefix = Prefix.objects.get(prefix='10.1.2.0/24')
         prefix = Prefix.objects.get(prefix='10.1.2.0/24')
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.vlan.vid, 102)
         self.assertEqual(prefix.vlan.vid, 102)
-        self.assertEqual(prefix.site.name, "Site 1")
+        self.assertEqual(prefix.scope, site)
 
 
 
 
 class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):

+ 4 - 4
netbox/ipam/views.py

@@ -352,7 +352,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return Prefix.objects.restrict(request.user, 'view').filter(
         return Prefix.objects.restrict(request.user, 'view').filter(
             prefix__net_contained_or_equal=str(parent.prefix)
             prefix__net_contained_or_equal=str(parent.prefix)
-        ).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan')
+        ).prefetch_related('scope', 'role', 'tenant', 'tenant__group', 'vlan')
 
 
     def prep_table_data(self, request, queryset, parent):
     def prep_table_data(self, request, queryset, parent):
         # Determine whether to show assigned prefixes, available prefixes, or both
         # Determine whether to show assigned prefixes, available prefixes, or both
@@ -492,7 +492,7 @@ class PrefixView(generic.ObjectView):
         ).filter(
         ).filter(
             prefix__net_contains=str(instance.prefix)
             prefix__net_contains=str(instance.prefix)
         ).prefetch_related(
         ).prefetch_related(
-            'site', 'role', 'tenant', 'vlan',
+            'scope', 'role', 'tenant', 'vlan',
         )
         )
         parent_prefix_table = tables.PrefixTable(
         parent_prefix_table = tables.PrefixTable(
             list(parent_prefixes),
             list(parent_prefixes),
@@ -506,7 +506,7 @@ class PrefixView(generic.ObjectView):
         ).exclude(
         ).exclude(
             pk=instance.pk
             pk=instance.pk
         ).prefetch_related(
         ).prefetch_related(
-            'site', 'role', 'tenant', 'vlan',
+            'scope', 'role', 'tenant', 'vlan',
         )
         )
         duplicate_prefix_table = tables.PrefixTable(
         duplicate_prefix_table = tables.PrefixTable(
             list(duplicate_prefixes),
             list(duplicate_prefixes),
@@ -538,7 +538,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
         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):
     def prep_table_data(self, request, queryset, parent):

+ 33 - 45
netbox/netbox/tests/test_authentication.py

@@ -4,12 +4,10 @@ from django.conf import settings
 from django.test import Client
 from django.test import Client
 from django.test.utils import override_settings
 from django.test.utils import override_settings
 from django.urls import reverse
 from django.urls import reverse
-from netaddr import IPNetwork
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
 from core.models import ObjectType
 from core.models import ObjectType
-from dcim.models import Site
-from ipam.models import Prefix
+from dcim.models import Rack, Site
 from users.models import Group, ObjectPermission, Token, User
 from users.models import Group, ObjectPermission, Token, User
 from utilities.testing import TestCase
 from utilities.testing import TestCase
 from utilities.testing.api import APITestCase
 from utilities.testing.api import APITestCase
@@ -410,18 +408,18 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         Site.objects.bulk_create(cls.sites)
         Site.objects.bulk_create(cls.sites)
 
 
-        cls.prefixes = (
-            Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]),
-            Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]),
-            Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]),
-            Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]),
-            Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]),
-            Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]),
-            Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]),
-            Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]),
-            Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]),
+        cls.racks = (
+            Rack(name='Rack 1', site=cls.sites[0]),
+            Rack(name='Rack 2', site=cls.sites[0]),
+            Rack(name='Rack 3', site=cls.sites[0]),
+            Rack(name='Rack 4', site=cls.sites[1]),
+            Rack(name='Rack 5', site=cls.sites[1]),
+            Rack(name='Rack 6', site=cls.sites[1]),
+            Rack(name='Rack 7', site=cls.sites[2]),
+            Rack(name='Rack 8', site=cls.sites[2]),
+            Rack(name='Rack 9', site=cls.sites[2]),
         )
         )
-        Prefix.objects.bulk_create(cls.prefixes)
+        Rack.objects.bulk_create(cls.racks)
 
 
     def setUp(self):
     def setUp(self):
         """
         """
@@ -435,8 +433,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_get_object(self):
     def test_get_object(self):
 
 
         # Attempt to retrieve object without permission
         # Attempt to retrieve object without permission
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
@@ -448,23 +445,21 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
 
 
         # Retrieve permitted object
         # Retrieve permitted object
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # Attempt to retrieve non-permitted object
         # Attempt to retrieve non-permitted object
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_list_objects(self):
     def test_list_objects(self):
-        url = reverse('ipam-api:prefix-list')
+        url = reverse('dcim-api:rack-list')
 
 
         # Attempt to list objects without permission
         # Attempt to list objects without permission
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
@@ -478,7 +473,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
 
 
         # Retrieve all objects. Only permitted objects should be returned.
         # Retrieve all objects. Only permitted objects should be returned.
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
@@ -487,12 +482,12 @@ class ObjectPermissionAPIViewTestCase(TestCase):
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_create_object(self):
     def test_create_object(self):
-        url = reverse('ipam-api:prefix-list')
+        url = reverse('dcim-api:rack-list')
         data = {
         data = {
-            'prefix': '10.0.9.0/24',
+            'name': 'Rack 10',
             'site': self.sites[1].pk,
             'site': self.sites[1].pk,
         }
         }
-        initial_count = Prefix.objects.count()
+        initial_count = Rack.objects.count()
 
 
         # Attempt to create an object without permission
         # Attempt to create an object without permission
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
@@ -506,26 +501,25 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
 
 
         # Attempt to create a non-permitted object
         # Attempt to create a non-permitted object
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(Prefix.objects.count(), initial_count)
+        self.assertEqual(Rack.objects.count(), initial_count)
 
 
         # Create a permitted object
         # Create a permitted object
         data['site'] = self.sites[0].pk
         data['site'] = self.sites[0].pk
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 201)
         self.assertEqual(response.status_code, 201)
-        self.assertEqual(Prefix.objects.count(), initial_count + 1)
+        self.assertEqual(Rack.objects.count(), initial_count + 1)
 
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_edit_object(self):
     def test_edit_object(self):
 
 
         # Attempt to edit an object without permission
         # Attempt to edit an object without permission
         data = {'site': self.sites[0].pk}
         data = {'site': self.sites[0].pk}
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
@@ -537,26 +531,23 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
 
 
         # Attempt to edit a non-permitted object
         # Attempt to edit a non-permitted object
         data = {'site': self.sites[0].pk}
         data = {'site': self.sites[0].pk}
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
         # Edit a permitted object
         # Edit a permitted object
         data['status'] = 'reserved'
         data['status'] = 'reserved'
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # Attempt to modify a permitted object to a non-permitted object
         # Attempt to modify a permitted object to a non-permitted object
         data['site'] = self.sites[1].pk
         data['site'] = self.sites[1].pk
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
@@ -564,8 +555,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_delete_object(self):
     def test_delete_object(self):
 
 
         # Attempt to delete an object without permission
         # Attempt to delete an object without permission
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.delete(url, format='json', **self.header)
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
@@ -577,16 +567,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         )
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)
-        obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
 
 
         # Attempt to delete a non-permitted object
         # Attempt to delete a non-permitted object
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
         response = self.client.delete(url, format='json', **self.header)
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
         # Delete a permitted object
         # Delete a permitted object
-        url = reverse('ipam-api:prefix-detail',
-                      kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
         response = self.client.delete(url, format='json', **self.header)
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 204)
         self.assertEqual(response.status_code, 204)

+ 6 - 10
netbox/templates/ipam/prefix.html

@@ -44,17 +44,13 @@
             {% endif %}
             {% endif %}
           </td>
           </td>
         </tr>
         </tr>
-        {% if object.site.region %}
-          <tr>
-            <th scope="row">{% trans "Region" %}</th>
-            <td>
-              {% nested_tree object.site.region %}
-            </td>
-          </tr>
-        {% endif %}
         <tr>
         <tr>
-          <th scope="row">{% trans "Site" %}</th>
-          <td>{{ object.site|linkify|placeholder }}</td>
+          <th scope="row">{% trans "Scope" %}</th>
+          {% if object.scope %}
+            <td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
+          {% else %}
+            <td>{{ ''|placeholder }}</td>
+          {% endif %}
         </tr>
         </tr>
         <tr>
         <tr>
           <th scope="row">{% trans "VLAN" %}</th>
           <th scope="row">{% trans "VLAN" %}</th>

+ 5 - 0
netbox/utilities/testing/base.py

@@ -1,5 +1,6 @@
 import json
 import json
 
 
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField, RangeField
 from django.contrib.postgres.fields import ArrayField, RangeField
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
@@ -120,6 +121,10 @@ class ModelTestCase(TestCase):
                 else:
                 else:
                     model_dict[key] = sorted([obj.pk for obj in value])
                     model_dict[key] = sorted([obj.pk for obj in value])
 
 
+            # Handle GenericForeignKeys
+            elif value and type(field) is GenericForeignKey:
+                model_dict[key] = value.pk
+
             elif api:
             elif api:
 
 
                 # Replace ContentType numeric IDs with <app_label>.<model>
                 # Replace ContentType numeric IDs with <app_label>.<model>