17929 Add Scope Mixins to Prefix (#17930)

* 17929 Add Scope Mixins to Prefix

* 17929 Add Scope Mixins to Prefix

* 17929 fixes for tests

* 17929 merge latest scope changes

* 12596 review changes

* 12596 review changes

* 12596 review changes

* 12596 review changes

* 12596 review changes

* 12596 review changes

* 17929 fix migrations
This commit is contained in:
Arthur Hanson 2024-11-15 11:55:46 -08:00 committed by GitHub
parent 6ab0792f02
commit 9fe6685562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 187 additions and 290 deletions

View File

@ -21,7 +21,7 @@ __all__ = (
class RegionSerializer(NestedGroupModelSerializer): class RegionSerializer(NestedGroupModelSerializer):
parent = NestedRegionSerializer(required=False, allow_null=True, default=None) parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0) site_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = Region model = Region
@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
class SiteGroupSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer):
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
site_count = serializers.IntegerField(read_only=True, default=0) site_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = SiteGroup model = SiteGroup
@ -63,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer):
# Related object counts # Related object counts
circuit_count = RelatedObjectCountField('circuit_terminations') circuit_count = RelatedObjectCountField('circuit_terminations')
device_count = RelatedObjectCountField('devices') device_count = RelatedObjectCountField('devices')
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
rack_count = RelatedObjectCountField('racks') rack_count = RelatedObjectCountField('racks')
vlan_count = RelatedObjectCountField('vlans') vlan_count = RelatedObjectCountField('vlans')
virtualmachine_count = RelatedObjectCountField('virtual_machines') virtualmachine_count = RelatedObjectCountField('virtual_machines')
@ -86,7 +86,7 @@ class LocationSerializer(NestedGroupModelSerializer):
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True, default=0) rack_count = serializers.IntegerField(read_only=True, default=0)
device_count = serializers.IntegerField(read_only=True, default=0) device_count = serializers.IntegerField(read_only=True, default=0)
prefix_count = RelatedObjectCountField('_prefixes') prefix_count = RelatedObjectCountField('prefix_set')
class Meta: class Meta:
model = Location model = Location

View File

@ -0,0 +1,67 @@
import django_filters
from django.utils.translation import gettext as _
from netbox.filtersets import BaseFilterSet
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
from .models import *
__all__ = (
'ScopedFilterSet',
)
class ScopedFilterSet(BaseFilterSet):
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_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(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)

View File

@ -73,7 +73,6 @@ __all__ = (
'RearPortFilterSet', 'RearPortFilterSet',
'RearPortTemplateFilterSet', 'RearPortTemplateFilterSet',
'RegionFilterSet', 'RegionFilterSet',
'ScopedFilterSet',
'SiteFilterSet', 'SiteFilterSet',
'SiteGroupFilterSet', 'SiteGroupFilterSet',
'VirtualChassisFilterSet', 'VirtualChassisFilterSet',
@ -2345,60 +2344,3 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet):
class Meta: class Meta:
model = Interface model = Interface
fields = tuple() fields = tuple()
class ScopedFilterSet(BaseFilterSet):
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_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(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)

View File

@ -461,7 +461,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -707,7 +707,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -739,7 +739,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
@ -763,7 +763,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.field @strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
return self._clusters.all() return self.cluster_set.all()
@strawberry_django.field @strawberry_django.field
def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:

View File

@ -59,28 +59,24 @@ class CachedScopeMixin(models.Model):
_location = models.ForeignKey( _location = models.ForeignKey(
to='dcim.Location', to='dcim.Location',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_site = models.ForeignKey( _site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_region = models.ForeignKey( _region = models.ForeignKey(
to='dcim.Region', to='dcim.Region',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )
_site_group = models.ForeignKey( _site_group = models.ForeignKey(
to='dcim.SiteGroup', to='dcim.SiteGroup',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='_%(class)ss',
blank=True, blank=True,
null=True null=True
) )

View File

@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from dcim.constants import LOCATION_SCOPE_TYPES
from ipam.choices import * from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, IPRange, Prefix from ipam.models import Aggregate, IPAddress, IPRange, Prefix
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer):
vrf = VRFSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True)
scope_type = ContentTypeField( scope_type = ContentTypeField(
queryset=ContentType.objects.filter( queryset=ContentType.objects.filter(
model__in=PREFIX_SCOPE_TYPES model__in=LOCATION_SCOPE_TYPES
), ),
allow_null=True, allow_null=True,
required=False, required=False,

View File

@ -18,7 +18,7 @@ class IPAMConfig(AppConfig):
# Register denormalized fields # Register denormalized fields
denormalized.register(Prefix, '_site', { denormalized.register(Prefix, '_site', {
'_region': 'region', '_region': 'region',
'_sitegroup': 'group', '_site_group': 'group',
}) })
denormalized.register(Prefix, '_location', { denormalized.register(Prefix, '_location', {
'_site': 'site', '_site': 'site',

View File

@ -23,11 +23,6 @@ VRF_RD_MAX_LENGTH = 21
PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MIN = 1
PREFIX_LENGTH_MAX = 127 # IPv6 PREFIX_LENGTH_MAX = 127 # IPv6
# models values for ContentTypes which may be Prefix scope types
PREFIX_SCOPE_TYPES = (
'region', 'sitegroup', 'site', 'location',
)
# #
# IPAddresses # IPAddresses

View File

@ -1,5 +1,6 @@
import django_filters import django_filters
import netaddr import netaddr
from dcim.base_filtersets import ScopedFilterSet
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
@ -9,7 +10,7 @@ from drf_spectacular.utils import extend_schema_field
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from circuits.models import Provider from circuits.models import Provider
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from dcim.models import Device, Interface, Region, Site, SiteGroup
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description', 'weight') fields = ('id', 'name', 'slug', 'description', 'weight')
class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='family' lookup_expr='family'
@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
to_field_name='rd', to_field_name='rd',
label=_('VRF (RD)'), label=_('VRF (RD)'),
) )
scope_type = ContentTypeFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
lookup_expr='in',
to_field_name='slug',
label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
field_name='_site',
label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='_site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
vlan_id = django_filters.ModelMultipleChoiceFilter( vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
label=_('VLAN (ID)'), label=_('VLAN (ID)'),

View File

@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.forms.mixins import ScopedBulkEditForm
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('description',) nullable_fields = ('description',)
class PrefixBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
required=False,
label=_('Scope type')
)
scope = DynamicModelChoiceField(
label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
vlan_group = DynamicModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments', 'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
) )
def __init__(self, *args, **kwargs):
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
class IPRangeBulkEditForm(NetBoxModelBulkEditForm): class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from dcim.forms.mixins import ScopedImportForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm):
fields = ('name', 'slug', 'weight', 'description', 'tags') fields = ('name', 'slug', 'weight', 'description', 'tags')
class PrefixImportForm(NetBoxModelImportForm): class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
vrf = CSVModelChoiceField( vrf = CSVModelChoiceField(
label=_('VRF'), label=_('VRF'),
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
required=False,
label=_('Scope type (app & model)')
)
vlan_group = CSVModelChoiceField( vlan_group = CSVModelChoiceField(
label=_('VLAN group'), label=_('VLAN group'),
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm):
'mark_utilized', 'description', 'comments', 'tags', 'mark_utilized', 'description', 'comments', 'tags',
) )
labels = { labels = {
'scope_id': 'Scope ID', 'scope_id': _('Scope ID'),
} }
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):

View File

@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from dcim.forms.mixins import ScopedForm
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm):
] ]
class PrefixForm(TenancyForm, NetBoxModelForm): class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label=_('VRF') label=_('VRF')
) )
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
widget=HTMXSelect(),
required=False,
label=_('Scope type')
)
scope = DynamicModelChoiceField(
label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
'tenant', 'description', 'comments', 'tags', '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): class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -154,7 +154,7 @@ class IPRangeType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.Prefix, models.Prefix,
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
filters=PrefixFilter filters=PrefixFilter
) )
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):

View File

@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor):
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
for prefix in prefixes: for prefix in prefixes:
prefix._region_id = prefix.site.region_id prefix._region_id = prefix.site.region_id
prefix._sitegroup_id = prefix.site.group_id prefix._site_group_id = prefix.site.group_id
prefix._site_id = prefix.site_id prefix._site_id = prefix.site_id
# Note: Location cannot be set prior to migration # Note: Location cannot be set prior to migration
Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site']) Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site'])
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -29,22 +29,22 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_location', name='_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_region', name='_region',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_site', name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
), ),
migrations.AddField( migrations.AddField(
model_name='prefix', model_name='prefix',
name='_sitegroup', name='_site_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'),
), ),
# Populate denormalized FK values # Populate denormalized FK values

View File

@ -1,5 +1,4 @@
import netaddr import netaddr
from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -9,6 +8,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType from core.models import ObjectType
from dcim.models.mixins import CachedScopeMixin
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField from ipam.fields import IPNetworkField, IPAddressField
@ -198,7 +198,7 @@ class Role(OrganizationalModel):
return self.name return self.name
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel):
""" """
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain 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. areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role.
@ -208,22 +208,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
verbose_name=_('prefix'), verbose_name=_('prefix'),
help_text=_('IPv4 or IPv6 network with mask') help_text=_('IPv4 or IPv6 network with mask')
) )
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES),
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( vrf = models.ForeignKey(
to='ipam.VRF', to='ipam.VRF',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
help_text=_("Treat as fully utilized") help_text=_("Treat as fully utilized")
) )
# Cached associations to enable efficient filtering
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
_sitegroup = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
related_name='_prefixes',
blank=True,
null=True
)
# Cached depth & child counts # Cached depth & child counts
_depth = models.PositiveSmallIntegerField( _depth = models.PositiveSmallIntegerField(
default=0, default=0,
@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def cache_related_objects(self):
self._region = self._sitegroup = self._site = self._location = None
if self.scope_type:
scope_type = self.scope_type.model_class()
if scope_type == apps.get_model('dcim', 'region'):
self._region = self.scope
elif scope_type == apps.get_model('dcim', 'sitegroup'):
self._sitegroup = self.scope
elif scope_type == apps.get_model('dcim', 'site'):
self._region = self.scope.region
self._sitegroup = self.scope.group
self._site = self.scope
elif scope_type == apps.get_model('dcim', 'location'):
self._region = self.scope.site.region
self._sitegroup = self.scope.site.group
self._site = self.scope.site
self._location = self.scope
cache_related_objects.alters_data = True
@property @property
def family(self): def family(self):
return self.prefix.version if self.prefix else None return self.prefix.version if self.prefix else None

View File

@ -129,7 +129,7 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None):
for field_name, field in serializer_class._declared_fields.items(): for field_name, field in serializer_class._declared_fields.items():
if field_name in fields_to_include and type(field) is RelatedObjectCountField: if field_name in fields_to_include and type(field) is RelatedObjectCountField:
related_field = model._meta.get_field(field.relation).field related_field = getattr(model, field.relation).field
annotations[field_name] = count_related(related_field.model, related_field.name) annotations[field_name] = count_related(related_field.model, related_field.name)
return annotations return annotations

View File

@ -2,7 +2,8 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet from dcim.filtersets import CommonInterfaceFilterSet
from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate from extras.models import ConfigTemplate

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.9 on 2024-11-14 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0196_qinq_svlan'),
('virtualization', '0045_clusters_cached_relations'),
]
operations = [
migrations.AlterField(
model_name='cluster',
name='_location',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
),
),
migrations.AlterField(
model_name='cluster',
name='_region',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
),
),
migrations.AlterField(
model_name='cluster',
name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
),
migrations.AlterField(
model_name='cluster',
name='_site_group',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
),
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('virtualization', '0045_clusters_cached_relations'), ('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'),
('dcim', '0197_natural_sort_collation'), ('dcim', '0197_natural_sort_collation'),
] ]

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.choices import LinkStatusChoices from dcim.choices import LinkStatusChoices
from dcim.filtersets import ScopedFilterSet from dcim.base_filtersets import ScopedFilterSet
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import VLAN
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.9 on 2024-11-14 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0196_qinq_svlan'),
('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'),
]
operations = [
migrations.AlterField(
model_name='wirelesslan',
name='_location',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'
),
),
migrations.AlterField(
model_name='wirelesslan',
name='_region',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'
),
),
migrations.AlterField(
model_name='wirelesslan',
name='_site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
),
migrations.AlterField(
model_name='wirelesslan',
name='_site_group',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'
),
),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), ('wireless', '0012_alter_wirelesslan__location_and_more'),
('dcim', '0197_natural_sort_collation'), ('dcim', '0197_natural_sort_collation'),
] ]