diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 535ffcec1..02aae12bc 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -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): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index ffd4d5b7d..eda173e34 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -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 diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 6dffd3287..c07b8441f 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -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 diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 894219c64..093b5c2f3 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -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)'), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 156e7c435..6ba2640e3 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -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( diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 46d45816e..cae3d91e6 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -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, diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py new file mode 100644 index 000000000..926c1ca57 --- /dev/null +++ b/netbox/ipam/migrations/0071_prefix_scope.py @@ -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'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a540b5810..80d7831f6 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -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, diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 8ec7a5967..f1ad61655 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -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 '', diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 4e38b1450..7bc372fbf 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -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'), ) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 95b311878..f14256175 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -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) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 67d56f15e..a049382e6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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 diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 94307cddb..2416fc0f7 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -44,17 +44,13 @@ {% endif %} - {% if object.site.region %} - - {% trans "Region" %} - - {% nested_tree object.site.region %} - - - {% endif %} - {% trans "Site" %} - {{ object.site|linkify|placeholder }} + {% trans "Scope" %} + {% if object.scope %} + {{ object.scope|linkify }} ({% trans object.scope_type.name %}) + {% else %} + {{ ''|placeholder }} + {% endif %} {% trans "VLAN" %}