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
This commit is contained in:
Jeremy Stretch 2024-10-18 15:45:22 -04:00 committed by GitHub
parent c78da79ce6
commit 75270c1aef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 457 additions and 153 deletions

View File

@ -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')

View File

@ -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',

View File

@ -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', 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope',
'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
'created', 'last_updated', 'children', '_depth', '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):

View File

@ -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',
})

View File

@ -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

View File

@ -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():

View File

@ -204,24 +204,18 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
class PrefixBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(NetBoxModelBulkEditForm):
region = DynamicModelChoiceField( scope_type = ContentTypeChoiceField(
label=_('Region'), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
queryset=Region.objects.all(), widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False
)
site_group = DynamicModelChoiceField(
label=_('Site group'),
queryset=SiteGroup.objects.all(),
required=False
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False, required=False,
query_params={ label=_('Scope type')
'region_id': '$region', )
'group_id': '$site_group', scope = DynamicModelChoiceField(
} label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
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(

View File

@ -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( scope_type = CSVContentTypeField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
queryset=Site.objects.all(),
required=False, required=False,
to_field_name='name', label=_('Scope type (app & model)')
help_text=_('Assigned site')
) )
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', 'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
'description', 'comments', 'tags', '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)

View File

@ -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,

View File

@ -201,12 +201,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
required=False, required=False,
label=_('VRF') label=_('VRF')
) )
site = DynamicModelChoiceField( scope_type = ContentTypeChoiceField(
label=_('Site'), queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
queryset=Site.objects.all(), widget=HTMXSelect(),
required=False, required=False,
selector=True, label=_('Scope type')
null_option='None' )
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', 'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', 'tenant_group',
'description', 'comments', 'tags', '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(

View File

@ -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,

View File

@ -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
),
]

View File

@ -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',
),
]

View File

@ -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 A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role.
assigned to a VLAN where appropriate. 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( scope_type = models.ForeignKey(
to='dcim.Site', 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, blank=True,
null=True null=True
) )
scope_id = models.PositiveBigIntegerField(
blank=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

View File

@ -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( scope_type = columns.ContentTypeColumn(
verbose_name=_('Site'), 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', 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments',
'created', 'last_updated', '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 '',

View File

@ -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.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], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'), 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], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), 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], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), 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, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True), 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], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]), 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], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), 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], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), 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'),
) )

View File

@ -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.1.0.0/16'), vrf=vrfs[0], scope=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.2.0.0/16'), vrf=vrfs[0], scope=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.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,prefix,status,scope_type,scope_id",
"VRF 1,10.4.0.0/16,active", f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
"VRF 1,10.5.0.0/16,active", f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
"VRF 1,10.6.0.0/16,active", 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 scope_type: dcim.site
site: Site 1 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):

View File

@ -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):

View File

@ -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 dcim.models import Rack, Site
from ipam.models import Prefix
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 = ( cls.racks = (
Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), Rack(name='Rack 1', site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), Rack(name='Rack 2', site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), Rack(name='Rack 3', site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), Rack(name='Rack 4', site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), Rack(name='Rack 5', site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), Rack(name='Rack 6', site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), Rack(name='Rack 7', site=cls.sites[2]),
Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), Rack(name='Rack 8', site=cls.sites[2]),
Prefix(prefix=IPNetwork('10.0.8.0/24'), 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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
kwargs={'pk': self.prefixes[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', url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
kwargs={'pk': self.prefixes[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)

View File

@ -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> <th scope="row">{% trans "Scope" %}</th>
<td>{{ object.site|linkify|placeholder }}</td> {% 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>

View File

@ -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>