Replace site FK on Prefix with scope GFK

This commit is contained in:
Jeremy Stretch 2024-10-07 15:36:22 -04:00
parent c78da79ce6
commit f28f1646b0
12 changed files with 241 additions and 124 deletions

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

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

@ -332,42 +332,42 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='rd', to_field_name='rd',
label=_('VRF (RD)'), label=_('VRF (RD)'),
) )
region_id = TreeNodeMultipleChoiceFilter( # region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), # queryset=Region.objects.all(),
field_name='site__region', # field_name='site__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='site__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='site__group',
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='site__group',
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(),
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)'),
) # )
vlan_id = django_filters.ModelMultipleChoiceFilter( vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
label=_('VLAN (ID)'), label=_('VLAN (ID)'),

View File

@ -204,25 +204,25 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
class PrefixBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(NetBoxModelBulkEditForm):
region = DynamicModelChoiceField( # region = DynamicModelChoiceField(
label=_('Region'), # label=_('Region'),
queryset=Region.objects.all(), # queryset=Region.objects.all(),
required=False # required=False
) # )
site_group = DynamicModelChoiceField( # site_group = DynamicModelChoiceField(
label=_('Site group'), # label=_('Site group'),
queryset=SiteGroup.objects.all(), # queryset=SiteGroup.objects.all(),
required=False # required=False
) # )
site = DynamicModelChoiceField( # site = DynamicModelChoiceField(
label=_('Site'), # label=_('Site'),
queryset=Site.objects.all(), # queryset=Site.objects.all(),
required=False, # required=False,
query_params={ # query_params={
'region_id': '$region', # 'region_id': '$region',
'group_id': '$site_group', # 'group_id': '$site_group',
} # }
) # )
vlan_group = DynamicModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
@ -282,12 +282,12 @@ 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('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('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', 'description', 'comments',
) )

View File

@ -167,13 +167,13 @@ class PrefixImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
site = CSVModelChoiceField( # site = CSVModelChoiceField(
label=_('Site'), # label=_('Site'),
queryset=Site.objects.all(), # queryset=Site.objects.all(),
required=False, # required=False,
to_field_name='name', # to_field_name='name',
help_text=_('Assigned site') # help_text=_('Assigned site')
) # )
vlan_group = CSVModelChoiceField( vlan_group = CSVModelChoiceField(
label=_('VLAN group'), label=_('VLAN group'),
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
@ -204,7 +204,7 @@ 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', 'is_pool', 'mark_utilized',
'description', 'comments', 'tags', 'description', 'comments', 'tags',
) )

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', name=_('Location')),
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(
@ -211,25 +211,25 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=PrefixStatusChoices, choices=PrefixStatusChoices,
required=False required=False
) )
region_id = DynamicModelMultipleChoiceField( # region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), # queryset=Region.objects.all(),
required=False, # required=False,
label=_('Region') # label=_('Region')
) # )
site_group_id = DynamicModelMultipleChoiceField( # site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), # queryset=SiteGroup.objects.all(),
required=False, # required=False,
label=_('Site group') # label=_('Site group')
) # )
site_id = DynamicModelMultipleChoiceField( # site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(), # queryset=Site.objects.all(),
required=False, # required=False,
null_option='None', # null_option='None',
query_params={ # query_params={
'region_id': '$region_id' # 'region_id': '$region_id'
}, # },
label=_('Site') # label=_('Site')
) # )
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', 'scope_type', 'scope', 'status', 'role', 'is_pool', 'mark_utilized',
'description', 'comments', 'tags', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.scope:
initial['scope'] = instance.scope
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if scope_type_id := get_field_value(self, 'scope_type'):
try:
scope_type = ContentType.objects.get(pk=scope_type_id)
model = scope_type.model_class()
self.fields['scope'].queryset = model.objects.all()
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
self.fields['scope'].disabled = False
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None
def clean(self):
super().clean()
# Assign the selected scope (if any)
self.instance.scope = self.cleaned_data.get('scope')
class IPRangeForm(TenancyForm, NetBoxModelForm): class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -0,0 +1,55 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_site_assignments(apps, schema_editor):
"""
Copy site ForeignKey values to the scope GFK.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
Prefix = apps.get_model('ipam', 'Prefix')
Prefix.objects.filter(site__isnull=False).update(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
scope_id=models.F('site_id')
)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0070_vlangroup_vlan_id_ranges'),
]
operations = [
# Add the scope GenericForeignKey
migrations.AddField(
model_name='prefix',
name='scope_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='prefix',
name='scope_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
),
),
# Copy over existing site assignments
migrations.RunPython(
code=copy_site_assignments,
reverse_code=migrations.RunPython.noop
),
# Delete the site ForeignKey
migrations.RemoveField(
model_name='prefix',
name='site',
),
]

View File

@ -199,21 +199,29 @@ 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', 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,
@ -275,7 +283,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:

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

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

@ -44,17 +44,9 @@
{% 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> <td>{{ object.scope|linkify|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "VLAN" %}</th> <th scope="row">{% trans "VLAN" %}</th>