Skip to content

Commit 5dd92ff

Browse files
committed
#6414: Add FKs for region, site group, and location on Prefix
1 parent 727de0f commit 5dd92ff

File tree

13 files changed

+209
-46
lines changed

13 files changed

+209
-46
lines changed

netbox/ipam/api/serializers_/ip.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from drf_spectacular.utils import extend_schema_field
33
from rest_framework import serializers
44

5-
from dcim.api.serializers_.sites import SiteSerializer
5+
from ipam.api.field_serializers import IPAddressField, IPNetworkField
66
from ipam.choices import *
7-
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
7+
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES
88
from ipam.models import Aggregate, IPAddress, IPRange, Prefix
99
from netbox.api.fields import ChoiceField, ContentTypeField
1010
from netbox.api.serializers import NetBoxModelSerializer
@@ -15,7 +15,6 @@
1515
from .roles import RoleSerializer
1616
from .vlans import VLANSerializer
1717
from .vrfs import VRFSerializer
18-
from ..field_serializers import IPAddressField, IPNetworkField
1918

2019
__all__ = (
2120
'AggregateSerializer',
@@ -45,7 +44,16 @@ class Meta:
4544

4645
class PrefixSerializer(NetBoxModelSerializer):
4746
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
48-
site = SiteSerializer(nested=True, required=False, allow_null=True)
47+
scope_type = ContentTypeField(
48+
queryset=ContentType.objects.filter(
49+
model__in=PREFIX_SCOPE_TYPES
50+
),
51+
allow_null=True,
52+
required=False,
53+
default=None
54+
)
55+
# TODO: Handle writing to scope
56+
scope = serializers.SerializerMethodField(read_only=True)
4957
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
5058
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
5159
vlan = VLANSerializer(nested=True, required=False, allow_null=True)
@@ -58,12 +66,20 @@ class PrefixSerializer(NetBoxModelSerializer):
5866
class Meta:
5967
model = Prefix
6068
fields = [
61-
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status',
62-
'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields',
69+
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'scope_type', 'scope', 'tenant',
70+
'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields',
6371
'created', 'last_updated', 'children', '_depth',
6472
]
6573
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
6674

75+
@extend_schema_field(serializers.JSONField(allow_null=True))
76+
def get_scope(self, obj):
77+
if obj.scope is None:
78+
return None
79+
serializer = get_serializer_for_model(obj.scope)
80+
context = {'request': self.context['request']}
81+
return serializer(obj.scope, nested=True, context=context).data
82+
6783

6884
class PrefixLengthSerializer(serializers.Serializer):
6985

netbox/ipam/api/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class RoleViewSet(NetBoxModelViewSet):
7979

8080

8181
class PrefixViewSet(NetBoxModelViewSet):
82-
queryset = Prefix.objects.all()
82+
queryset = Prefix.objects.prefetch_related('region', 'site_group', 'site', 'location')
8383
serializer_class = serializers.PrefixSerializer
8484
filterset_class = filtersets.PrefixFilterSet
8585

netbox/ipam/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
PREFIX_LENGTH_MIN = 1
2424
PREFIX_LENGTH_MAX = 127 # IPv6
2525

26+
# models values for ContentTypes which may be Prefix scope types
27+
PREFIX_SCOPE_TYPES = (
28+
'region', 'sitegroup', 'site', 'location',
29+
)
30+
2631

2732
#
2833
# IPAddresses

netbox/ipam/filtersets.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from netaddr.core import AddrFormatError
1010

1111
from circuits.models import Provider
12-
from dcim.models import Device, Interface, Region, Site, SiteGroup
12+
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
1313
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
1414
from tenancy.filtersets import TenancyFilterSet
1515
from utilities.filters import (
@@ -332,6 +332,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
332332
to_field_name='rd',
333333
label=_('VRF (RD)'),
334334
)
335+
# TODO: Figure out region & site filters
335336
region_id = TreeNodeMultipleChoiceFilter(
336337
queryset=Region.objects.all(),
337338
field_name='site__region',
@@ -368,6 +369,17 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
368369
to_field_name='slug',
369370
label=_('Site (slug)'),
370371
)
372+
location_id = TreeNodeMultipleChoiceFilter(
373+
queryset=Location.objects.all(),
374+
lookup_expr='in',
375+
label=_('Location (ID)'),
376+
)
377+
location = TreeNodeMultipleChoiceFilter(
378+
queryset=Location.objects.all(),
379+
lookup_expr='in',
380+
to_field_name='slug',
381+
label=_('Location (slug)'),
382+
)
371383
vlan_id = django_filters.ModelMultipleChoiceFilter(
372384
queryset=VLAN.objects.all(),
373385
label=_('VLAN (ID)'),

netbox/ipam/forms/model_forms.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
201201
required=False,
202202
label=_('VRF')
203203
)
204-
site = DynamicModelChoiceField(
205-
label=_('Site'),
206-
queryset=Site.objects.all(),
204+
scope_type = ContentTypeChoiceField(
205+
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
206+
widget=HTMXSelect(),
207207
required=False,
208-
selector=True,
209-
null_option='None'
208+
label=_('Scope type')
209+
)
210+
scope = DynamicModelChoiceField(
211+
label=_('Scope'),
212+
queryset=Site.objects.none(), # Initial queryset
213+
required=False,
214+
disabled=True,
215+
selector=True
210216
)
211217
vlan = DynamicModelChoiceField(
212218
queryset=VLAN.objects.all(),
@@ -228,7 +234,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
228234
FieldSet(
229235
'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')
230236
),
231-
FieldSet('site', 'vlan', name=_('Site/VLAN Assignment')),
237+
FieldSet('scope_type', 'scope', name=_('Scope')),
238+
FieldSet('vlan', name=_('VLAN Assignment')),
232239
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
233240
)
234241

@@ -239,6 +246,32 @@ class Meta:
239246
'description', 'comments', 'tags',
240247
]
241248

249+
def __init__(self, *args, **kwargs):
250+
instance = kwargs.get('instance')
251+
initial = kwargs.get('initial', {})
252+
253+
if instance is not None and instance.scope and 'scope_type' not in initial:
254+
initial['scope_type'] = instance.scope_type.pk
255+
initial['scope'] = instance.scope
256+
kwargs['initial'] = initial
257+
258+
super().__init__(*args, **kwargs)
259+
260+
if scope_type := get_field_value(self, 'scope_type'):
261+
try:
262+
scope_type = ContentType.objects.get(pk=scope_type)
263+
model = scope_type.model_class()
264+
self.fields['scope'].queryset = model.objects.all()
265+
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
266+
self.fields['scope'].disabled = False
267+
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
268+
except ObjectDoesNotExist:
269+
pass
270+
271+
def save(self, *args, **kwargs):
272+
self.instance.scope = self.cleaned_data['scope']
273+
return super().save(*args, **kwargs)
274+
242275

243276
class IPRangeForm(TenancyForm, NetBoxModelForm):
244277
vrf = DynamicModelChoiceField(

netbox/ipam/graphql/types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
163163
vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
164164
role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
165165

166+
@strawberry_django.field
167+
def scope(self) -> Annotated[Union[
168+
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
169+
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
170+
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
171+
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
172+
], strawberry.union("PrefixScopeType")] | None:
173+
return self.scope
174+
166175

167176
@strawberry_django.type(
168177
models.RIR,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
dependencies = [
8+
('dcim', '0193_poweroutlet_color'),
9+
('ipam', '0070_vlangroup_vlan_id_ranges'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='prefix',
15+
name='location',
16+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.location'),
17+
),
18+
migrations.AddField(
19+
model_name='prefix',
20+
name='region',
21+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.region'),
22+
),
23+
migrations.AddField(
24+
model_name='prefix',
25+
name='site_group',
26+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.sitegroup'),
27+
),
28+
]

netbox/ipam/models/ip.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import netaddr
2+
from django.apps import apps
23
from django.contrib.contenttypes.fields import GenericForeignKey
34
from django.core.exceptions import ValidationError
45
from django.db import models
@@ -207,13 +208,34 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
207208
verbose_name=_('prefix'),
208209
help_text=_('IPv4 or IPv6 network with mask')
209210
)
211+
region = models.ForeignKey(
212+
to='dcim.Region',
213+
on_delete=models.PROTECT,
214+
related_name='prefixes',
215+
blank=True,
216+
null=True
217+
)
218+
site_group = models.ForeignKey(
219+
to='dcim.SiteGroup',
220+
on_delete=models.PROTECT,
221+
related_name='prefixes',
222+
blank=True,
223+
null=True
224+
)
210225
site = models.ForeignKey(
211226
to='dcim.Site',
212227
on_delete=models.PROTECT,
213228
related_name='prefixes',
214229
blank=True,
215230
null=True
216231
)
232+
location = models.ForeignKey(
233+
to='dcim.Location',
234+
on_delete=models.PROTECT,
235+
related_name='prefixes',
236+
blank=True,
237+
null=True
238+
)
217239
vrf = models.ForeignKey(
218240
to='ipam.VRF',
219241
on_delete=models.PROTECT,
@@ -275,7 +297,8 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
275297
objects = PrefixQuerySet.as_manager()
276298

277299
clone_fields = (
278-
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
300+
'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
301+
# 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'scope_type', 'scope',
279302
)
280303

281304
class Meta:
@@ -341,6 +364,27 @@ def depth(self):
341364
def children(self):
342365
return self._children
343366

367+
@property
368+
def scope_type(self):
369+
if not self.scope:
370+
return None
371+
return ObjectType.objects.get_for_model(self.scope)
372+
373+
@property
374+
def scope(self):
375+
return self.region or self.site_group or self.site or self.location
376+
377+
@scope.setter
378+
def scope(self, value):
379+
self.region = self.site_group = self.site = self.location = None
380+
if value is not None:
381+
if value._meta.model_name == 'sitegroup':
382+
# TODO: Fix this hack
383+
field_name = 'site_group'
384+
else:
385+
field_name = value._meta.model_name
386+
setattr(self, field_name, value)
387+
344388
def _set_prefix_length(self, value):
345389
"""
346390
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,

netbox/ipam/tables/ip.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,30 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
241241
template_code=VRF_LINK,
242242
verbose_name=_('VRF')
243243
)
244+
scope_type = columns.ContentTypeColumn(
245+
verbose_name=_('Scope Type'),
246+
)
247+
scope = tables.Column(
248+
linkify=True,
249+
orderable=False,
250+
verbose_name=_('Scope')
251+
)
252+
region = tables.Column(
253+
verbose_name=_('Region'),
254+
linkify=True
255+
)
256+
site_group = tables.Column(
257+
verbose_name=_('Site Group'),
258+
linkify=True
259+
)
244260
site = tables.Column(
245261
verbose_name=_('Site'),
246262
linkify=True
247263
)
264+
location = tables.Column(
265+
verbose_name=_('Location'),
266+
linkify=True
267+
)
248268
vlan_group = tables.Column(
249269
accessor='vlan__group',
250270
linkify=True,
@@ -285,11 +305,11 @@ class Meta(NetBoxTable.Meta):
285305
model = Prefix
286306
fields = (
287307
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
288-
'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
289-
'created', 'last_updated',
308+
'region', 'site_group', 'site', 'location', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized',
309+
'description', 'comments', 'tags', 'created', 'last_updated',
290310
)
291311
default_columns = (
292-
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
312+
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'vlan', 'role', 'description',
293313
)
294314
row_attrs = {
295315
'class': lambda record: 'success' if not record.pk else '',

netbox/ipam/tests/test_filtersets.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -656,14 +656,14 @@ def setUpTestData(cls):
656656
Tenant.objects.bulk_create(tenants)
657657

658658
prefixes = (
659-
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'),
660-
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
661-
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),
662-
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),
663-
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
664-
Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
665-
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),
666-
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),
659+
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'),
660+
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
661+
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),
662+
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),
663+
Prefix(prefix='2001:db8::/64', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
664+
Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
665+
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),
666+
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),
667667
Prefix(prefix='10.0.0.0/16'),
668668
Prefix(prefix='2001:db8::/32'),
669669
)

netbox/ipam/tests/test_views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,9 @@ def setUpTestData(cls):
409409
Role.objects.bulk_create(roles)
410410

411411
prefixes = (
412-
Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
413-
Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
414-
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
412+
Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
413+
Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
414+
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
415415
)
416416
Prefix.objects.bulk_create(prefixes)
417417

0 commit comments

Comments
 (0)