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

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

@ -332,42 +332,42 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='rd',
label=_('VRF (RD)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
# region_id = TreeNodeMultipleChoiceFilter(
# queryset=Region.objects.all(),
# field_name='site__region',
# lookup_expr='in',
# label=_('Region (ID)'),
# )
# region = TreeNodeMultipleChoiceFilter(
# queryset=Region.objects.all(),
# field_name='site__region',
# lookup_expr='in',
# to_field_name='slug',
# label=_('Region (slug)'),
# )
# site_group_id = TreeNodeMultipleChoiceFilter(
# queryset=SiteGroup.objects.all(),
# field_name='site__group',
# lookup_expr='in',
# label=_('Site group (ID)'),
# )
# site_group = TreeNodeMultipleChoiceFilter(
# queryset=SiteGroup.objects.all(),
# field_name='site__group',
# lookup_expr='in',
# to_field_name='slug',
# label=_('Site group (slug)'),
# )
# site_id = django_filters.ModelMultipleChoiceFilter(
# queryset=Site.objects.all(),
# label=_('Site (ID)'),
# )
# site = django_filters.ModelMultipleChoiceFilter(
# field_name='site__slug',
# queryset=Site.objects.all(),
# to_field_name='slug',
# label=_('Site (slug)'),
# )
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('VLAN (ID)'),

View File

@ -204,25 +204,25 @@ 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(),
required=False,
query_params={
'region_id': '$region',
'group_id': '$site_group',
}
)
# 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(),
# required=False,
# query_params={
# 'region_id': '$region',
# 'group_id': '$site_group',
# }
# )
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
@ -282,12 +282,12 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
model = Prefix
fieldsets = (
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('vlan_group', 'vlan', name=_('VLAN Assignment')),
)
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',
help_text=_('Assigned tenant')
)
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned site')
)
# site = CSVModelChoiceField(
# label=_('Site'),
# queryset=Site.objects.all(),
# required=False,
# to_field_name='name',
# help_text=_('Assigned site')
# )
vlan_group = CSVModelChoiceField(
label=_('VLAN group'),
queryset=VLANGroup.objects.all(),
@ -204,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm):
class Meta:
model = Prefix
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',
)

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', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
mask_length__lte = forms.IntegerField(
@ -211,25 +211,25 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=PrefixStatusChoices,
required=False
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
label=_('Region')
)
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
site_id = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
null_option='None',
query_params={
'region_id': '$region_id'
},
label=_('Site')
)
# region_id = DynamicModelMultipleChoiceField(
# queryset=Region.objects.all(),
# required=False,
# label=_('Region')
# )
# site_group_id = DynamicModelMultipleChoiceField(
# queryset=SiteGroup.objects.all(),
# required=False,
# label=_('Site group')
# )
# site_id = DynamicModelMultipleChoiceField(
# queryset=Site.objects.all(),
# required=False,
# null_option='None',
# query_params={
# 'region_id': '$region_id'
# },
# label=_('Site')
# )
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', 'scope_type', 'scope', 'status', 'role', 'is_pool', 'mark_utilized',
'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

@ -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):
"""
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',
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,
@ -275,7 +283,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:

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

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

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