Просмотр исходного кода

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 год назад
Родитель
Сommit
75270c1aef

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

@@ -21,12 +21,13 @@ __all__ = (
 class RegionSerializer(NestedGroupModelSerializer):
     parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
     class Meta:
         model = Region
         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')
 
@@ -34,12 +35,13 @@ class RegionSerializer(NestedGroupModelSerializer):
 class SiteGroupSerializer(NestedGroupModelSerializer):
     parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
     site_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
     class Meta:
         model = SiteGroup
         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')
 
@@ -61,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer):
     # Related object counts
     circuit_count = RelatedObjectCountField('circuit_terminations')
     device_count = RelatedObjectCountField('devices')
-    prefix_count = RelatedObjectCountField('prefixes')
+    prefix_count = RelatedObjectCountField('_prefixes')
     rack_count = RelatedObjectCountField('racks')
     vlan_count = RelatedObjectCountField('vlans')
     virtualmachine_count = RelatedObjectCountField('virtual_machines')
@@ -84,11 +86,13 @@ class LocationSerializer(NestedGroupModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     rack_count = serializers.IntegerField(read_only=True, default=0)
     device_count = serializers.IntegerField(read_only=True, default=0)
+    prefix_count = RelatedObjectCountField('_prefixes')
 
     class Meta:
         model = Location
         fields = [
             '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')

+ 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
     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(
         to='ipam.VLANGroup',
         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
     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(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
@@ -214,6 +226,12 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
     )
 
     # Generic relations
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='site'
+    )
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         content_type_field='scope_type',
@@ -273,6 +291,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
     )
 
     # Generic relations
+    prefixes = GenericRelation(
+        to='ipam.Prefix',
+        content_type_field='scope_type',
+        object_id_field='scope_id',
+        related_query_name='location'
+    )
     vlan_groups = GenericRelation(
         to='ipam.VLANGroup',
         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 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):
 

+ 12 - 0
netbox/ipam/apps.py

@@ -1,5 +1,7 @@
 from django.apps import AppConfig
 
+from netbox import denormalized
+
 
 class IPAMConfig(AppConfig):
     name = "ipam"
@@ -8,6 +10,16 @@ class IPAMConfig(AppConfig):
     def ready(self):
         from netbox.models.features import register_models
         from . import signals, search  # noqa: F401
+        from .models import Prefix
 
         # Register 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_MAX = 127  # IPv6
 
+# models values for ContentTypes which may be Prefix scope types
+PREFIX_SCOPE_TYPES = (
+    'region', 'sitegroup', 'site', 'location',
+)
+
 
 #
 # 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 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 tenancy.filtersets import TenancyFilterSet
 from utilities.filters import (
@@ -332,42 +332,57 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
+    scope_type = ContentTypeFilter()
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='site__region',
+        field_name='_region',
         lookup_expr='in',
         label=_('Region (ID)'),
     )
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
-        field_name='site__region',
+        field_name='_region',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Region (slug)'),
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='site__group',
+        field_name='_sitegroup',
         lookup_expr='in',
         label=_('Site group (ID)'),
     )
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
-        field_name='site__group',
+        field_name='_sitegroup',
         lookup_expr='in',
         to_field_name='slug',
         label=_('Site group (slug)'),
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        field_name='_site',
         label=_('Site (ID)'),
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
+        field_name='_site__slug',
         queryset=Site.objects.all(),
         to_field_name='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(
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),
@@ -393,7 +408,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 
     class Meta:
         model = Prefix
-        fields = ('id', 'is_pool', 'mark_utilized', 'description')
+        fields = ('id', 'scope_id', 'is_pool', 'mark_utilized', 'description')
 
     def search(self, queryset, name, value):
         if not value.strip():

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

@@ -204,24 +204,18 @@ 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
+    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,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
+        disabled=True,
+        selector=True
     )
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
@@ -282,14 +276,28 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
     model = Prefix
     fieldsets = (
         FieldSet('tenant', 'status', 'role', 'description'),
-        FieldSet('region', 'site_group', 'site', name=_('Site')),
         FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
+        FieldSet('scope_type', 'scope', name=_('Scope')),
         FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
     )
     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):
     vrf = DynamicModelChoiceField(

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

@@ -167,12 +167,10 @@ class PrefixImportForm(NetBoxModelImportForm):
         to_field_name='name',
         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,
-        to_field_name='name',
-        help_text=_('Assigned site')
+        label=_('Scope type (app & model)')
     )
     vlan_group = CSVModelChoiceField(
         label=_('VLAN group'),
@@ -204,9 +202,12 @@ class PrefixImportForm(NetBoxModelImportForm):
     class Meta:
         model = Prefix
         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):
         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('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')),
     )
     mask_length__lte = forms.IntegerField(
@@ -224,12 +224,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        null_option='None',
-        query_params={
-            'region_id': '$region_id'
-        },
         label=_('Site')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
     role_id = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         required=False,

+ 45 - 8
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', '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):
     vrf = DynamicModelChoiceField(

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

@@ -152,17 +152,25 @@ class IPRangeType(NetBoxObjectType):
 
 @strawberry_django.type(
     models.Prefix,
-    fields='__all__',
+    exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'),
     filters=PrefixFilter
 )
 class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
     prefix: str
-    site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
     vlan: Annotated["VLANType", 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(
     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
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.db import models
@@ -199,21 +200,30 @@ 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',
+        limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES),
+        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,
@@ -262,6 +272,36 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
         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
     _depth = models.PositiveSmallIntegerField(
         default=0,
@@ -275,7 +315,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:
@@ -323,8 +363,30 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
             # Clear host bits from prefix
             self.prefix = self.prefix.cidr
 
+        # Cache objects associated with the terminating object (for filtering)
+        self.cache_related_objects()
+
         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
     def family(self):
         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,
         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 '',

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

@@ -656,14 +656,14 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
 
         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='2001:db8::/32'),
         )

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

@@ -1,5 +1,6 @@
 import datetime
 
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 from netaddr import IPNetwork
@@ -409,9 +410,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         Role.objects.bulk_create(roles)
 
         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)
 
@@ -419,7 +420,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         cls.form_data = {
             '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,
             'tenant': None,
             'vlan': None,
@@ -430,11 +432,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tags': [t.pk for t in tags],
         }
 
+        site = sites[0].pk
         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 = (
@@ -445,7 +448,6 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
 
         cls.bulk_edit_data = {
-            'site': sites[1].pk,
             'vrf': vrfs[1].pk,
             'tenant': None,
             'status': PrefixStatusChoices.STATUS_RESERVED,
@@ -501,11 +503,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         """
         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
 status: active
 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
         VLAN.objects.create(vid=101, name='VLAN101')
@@ -523,19 +527,21 @@ site: Site 1
         prefix = Prefix.objects.get(prefix='10.1.1.0/24')
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.vlan.vid, 101)
-        self.assertEqual(prefix.site.name, "Site 1")
+        self.assertEqual(prefix.scope, site)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_prefix_import_with_vlan_group(self):
         """
         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
 status: active
-vlan: 102
-site: Site 1
+scope_type: dcim.site
+scope_id: {site.pk}
 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.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')
         self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
         self.assertEqual(prefix.vlan.vid, 102)
-        self.assertEqual(prefix.site.name, "Site 1")
+        self.assertEqual(prefix.scope, site)
 
 
 class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):

+ 4 - 4
netbox/ipam/views.py

@@ -352,7 +352,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
     def get_children(self, request, parent):
         return Prefix.objects.restrict(request.user, 'view').filter(
             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):
         # Determine whether to show assigned prefixes, available prefixes, or both
@@ -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):

+ 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.utils import override_settings
 from django.urls import reverse
-from netaddr import IPNetwork
 from rest_framework.test import APIClient
 
 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 utilities.testing import TestCase
 from utilities.testing.api import APITestCase
@@ -410,18 +408,18 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         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):
         """
@@ -435,8 +433,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_get_object(self):
 
         # 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)
         self.assertEqual(response.status_code, 403)
 
@@ -448,23 +445,21 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         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
-        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)
         self.assertEqual(response.status_code, 200)
 
         # 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)
         self.assertEqual(response.status_code, 404)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_list_objects(self):
-        url = reverse('ipam-api:prefix-list')
+        url = reverse('dcim-api:rack-list')
 
         # Attempt to list objects without permission
         response = self.client.get(url, **self.header)
@@ -478,7 +473,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         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.
         response = self.client.get(url, **self.header)
@@ -487,12 +482,12 @@ class ObjectPermissionAPIViewTestCase(TestCase):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_create_object(self):
-        url = reverse('ipam-api:prefix-list')
+        url = reverse('dcim-api:rack-list')
         data = {
-            'prefix': '10.0.9.0/24',
+            'name': 'Rack 10',
             'site': self.sites[1].pk,
         }
-        initial_count = Prefix.objects.count()
+        initial_count = Rack.objects.count()
 
         # Attempt to create an object without permission
         response = self.client.post(url, data, format='json', **self.header)
@@ -506,26 +501,25 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         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
         response = self.client.post(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(Prefix.objects.count(), initial_count)
+        self.assertEqual(Rack.objects.count(), initial_count)
 
         # Create a permitted object
         data['site'] = self.sites[0].pk
         response = self.client.post(url, data, format='json', **self.header)
         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=[])
     def test_edit_object(self):
 
         # Attempt to edit an object without permission
         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)
         self.assertEqual(response.status_code, 403)
 
@@ -537,26 +531,23 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         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
         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)
         self.assertEqual(response.status_code, 404)
 
         # Edit a permitted object
         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)
         self.assertEqual(response.status_code, 200)
 
         # Attempt to modify a permitted object to a non-permitted object
         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)
         self.assertEqual(response.status_code, 403)
 
@@ -564,8 +555,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_delete_object(self):
 
         # 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)
         self.assertEqual(response.status_code, 403)
 
@@ -577,16 +567,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         )
         obj_perm.save()
         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
-        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)
         self.assertEqual(response.status_code, 404)
 
         # 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)
         self.assertEqual(response.status_code, 204)

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

@@ -44,17 +44,13 @@
             {% endif %}
           </td>
         </tr>
-        {% if object.site.region %}
-          <tr>
-            <th scope="row">{% trans "Region" %}</th>
-            <td>
-              {% nested_tree object.site.region %}
-            </td>
-          </tr>
-        {% endif %}
         <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>
           <th scope="row">{% trans "VLAN" %}</th>

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

@@ -1,5 +1,6 @@
 import json
 
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField, RangeField
 from django.core.exceptions import FieldDoesNotExist
@@ -120,6 +121,10 @@ class ModelTestCase(TestCase):
                 else:
                     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:
 
                 # Replace ContentType numeric IDs with <app_label>.<model>