#6414: Add FKs for region, site group, and location on Prefix

This commit is contained in:
Jeremy Stretch 2024-10-11 15:48:13 -04:00
parent 727de0fb59
commit 5dd92ff048
13 changed files with 209 additions and 46 deletions

View File

@ -2,9 +2,9 @@ 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.api.field_serializers import IPAddressField, IPNetworkField
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
@ -15,7 +15,6 @@ from .nested import NestedIPAddressSerializer
from .roles import RoleSerializer
from .vlans import VLANSerializer
from .vrfs import VRFSerializer
from ..field_serializers import IPAddressField, IPNetworkField
__all__ = (
'AggregateSerializer',
@ -45,7 +44,16 @@ class AggregateSerializer(NetBoxModelSerializer):
class PrefixSerializer(NetBoxModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = SiteSerializer(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
)
# TODO: Handle writing to scope
scope = serializers.SerializerMethodField(read_only=True)
vrf = VRFSerializer(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)
@ -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',
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'scope_type', '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 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

@ -79,7 +79,7 @@ class RoleViewSet(NetBoxModelViewSet):
class PrefixViewSet(NetBoxModelViewSet):
queryset = Prefix.objects.all()
queryset = Prefix.objects.prefetch_related('region', 'site_group', 'site', 'location')
serializer_class = serializers.PrefixSerializer
filterset_class = filtersets.PrefixFilterSet

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,6 +332,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='rd',
label=_('VRF (RD)'),
)
# TODO: Figure out region & site filters
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@ -368,6 +369,17 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(),
label=_('VLAN (ID)'),

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,7 +234,8 @@ 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')),
)
@ -239,6 +246,32 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance is not None and instance.scope and 'scope_type' not in initial:
initial['scope_type'] = instance.scope_type.pk
initial['scope'] = instance.scope
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if scope_type := get_field_value(self, 'scope_type'):
try:
scope_type = ContentType.objects.get(pk=scope_type)
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
def save(self, *args, **kwargs):
self.instance.scope = self.cleaned_data['scope']
return super().save(*args, **kwargs)
class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField(

View File

@ -163,6 +163,15 @@ class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
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,28 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0193_poweroutlet_color'),
('ipam', '0070_vlangroup_vlan_id_ranges'),
]
operations = [
migrations.AddField(
model_name='prefix',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, 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.PROTECT, related_name='prefixes', to='dcim.region'),
),
migrations.AddField(
model_name='prefix',
name='site_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.sitegroup'),
),
]

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
@ -207,6 +208,20 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
verbose_name=_('prefix'),
help_text=_('IPv4 or IPv6 network with mask')
)
region = models.ForeignKey(
to='dcim.Region',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True
)
site_group = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True
)
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.PROTECT,
@ -214,6 +229,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
blank=True,
null=True
)
location = models.ForeignKey(
to='dcim.Location',
on_delete=models.PROTECT,
related_name='prefixes',
blank=True,
null=True
)
vrf = models.ForeignKey(
to='ipam.VRF',
on_delete=models.PROTECT,
@ -275,7 +297,8 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
objects = PrefixQuerySet.as_manager()
clone_fields = (
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
# 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'scope_type', 'scope',
)
class Meta:
@ -341,6 +364,27 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
def children(self):
return self._children
@property
def scope_type(self):
if not self.scope:
return None
return ObjectType.objects.get_for_model(self.scope)
@property
def scope(self):
return self.region or self.site_group or self.site or self.location
@scope.setter
def scope(self, value):
self.region = self.site_group = self.site = self.location = None
if value is not None:
if value._meta.model_name == 'sitegroup':
# TODO: Fix this hack
field_name = 'site_group'
else:
field_name = value._meta.model_name
setattr(self, field_name, value)
def _set_prefix_length(self, value):
"""
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

View File

@ -241,10 +241,30 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
template_code=VRF_LINK,
verbose_name=_('VRF')
)
scope_type = columns.ContentTypeColumn(
verbose_name=_('Scope Type'),
)
scope = tables.Column(
linkify=True,
orderable=False,
verbose_name=_('Scope')
)
region = tables.Column(
verbose_name=_('Region'),
linkify=True
)
site_group = tables.Column(
verbose_name=_('Site Group'),
linkify=True
)
site = tables.Column(
verbose_name=_('Site'),
linkify=True
)
location = tables.Column(
verbose_name=_('Location'),
linkify=True
)
vlan_group = tables.Column(
accessor='vlan__group',
linkify=True,
@ -285,11 +305,11 @@ 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',
'region', 'site_group', 'site', 'location', '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', '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

@ -409,9 +409,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)

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('region', 'site_group', 'site', 'location', 'role', 'tenant', 'tenant__group', 'vlan')
def prep_table_data(self, request, queryset, parent):
# Determine whether to show assigned prefixes, available prefixes, or both
@ -467,7 +467,7 @@ class RoleBulkDeleteView(generic.BulkDeleteView):
#
class PrefixListView(generic.ObjectListView):
queryset = Prefix.objects.all()
queryset = Prefix.objects.prefetch_related('region', 'site_group', 'site', 'location')
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
table = tables.PrefixTable
@ -492,7 +492,7 @@ class PrefixView(generic.ObjectView):
).filter(
prefix__net_contains=str(instance.prefix)
).prefetch_related(
'site', 'role', 'tenant', 'vlan',
'region', 'site_group', 'site', 'location', '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',
'region', 'site_group', 'site', 'location', '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'
'region', 'site_group', 'site', 'location', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group'
)
def prep_table_data(self, request, queryset, parent):
@ -631,14 +631,14 @@ class PrefixBulkImportView(generic.BulkImportView):
class PrefixBulkEditView(generic.BulkEditView):
queryset = Prefix.objects.prefetch_related('vrf__tenant')
queryset = Prefix.objects.prefetch_related('region', 'site_group', 'site', 'location')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable
form = forms.PrefixBulkEditForm
class PrefixBulkDeleteView(generic.BulkDeleteView):
queryset = Prefix.objects.prefetch_related('vrf__tenant')
queryset = Prefix.objects.prefetch_related('region', 'site_group', 'site', 'location')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable

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>