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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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