Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #6414: Additional ForeignKeys for prefix scope #17746

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions netbox/ipam/api/serializers_/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
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
Expand All @@ -15,7 +15,6 @@
from .roles import RoleSerializer
from .vlans import VLANSerializer
from .vrfs import VRFSerializer
from ..field_serializers import IPAddressField, IPNetworkField

__all__ = (
'AggregateSerializer',
Expand Down Expand Up @@ -45,7 +44,16 @@ class Meta:

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

Expand Down
2 changes: 1 addition & 1 deletion netbox/ipam/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions netbox/ipam/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
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
Expand Down
14 changes: 13 additions & 1 deletion netbox/ipam/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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 (
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)'),
Expand Down
45 changes: 39 additions & 6 deletions netbox/ipam/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand 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')),
)

Expand All @@ -239,6 +246,32 @@ class Meta:
'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(
Expand Down
9 changes: 9 additions & 0 deletions netbox/ipam/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions netbox/ipam/migrations/0071_prefix_scope.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
46 changes: 45 additions & 1 deletion netbox/ipam/models/ip.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -207,13 +208,34 @@ 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,
related_name='prefixes',
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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -341,6 +364,27 @@ def depth(self):
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,
Expand Down
26 changes: 23 additions & 3 deletions netbox/ipam/tables/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -285,11 +305,11 @@ class Meta(NetBoxTable.Meta):
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 '',
Expand Down
16 changes: 8 additions & 8 deletions netbox/ipam/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,14 +656,14 @@ def setUpTestData(cls):
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'),
)
Expand Down
6 changes: 3 additions & 3 deletions netbox/ipam/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,9 @@ def setUpTestData(cls):
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)

Expand Down
Loading
Loading