Kaynağa Gözat

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
Arthur Hanson 1 yıl önce
ebeveyn
işleme
9fe6685562

+ 4 - 4
netbox/dcim/api/serializers_/sites.py

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

+ 67 - 0
netbox/dcim/base_filtersets.py

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

+ 0 - 58
netbox/dcim/filtersets.py

@@ -73,7 +73,6 @@ __all__ = (
     'RearPortFilterSet',
     'RearPortTemplateFilterSet',
     'RegionFilterSet',
-    'ScopedFilterSet',
     'SiteFilterSet',
     'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
@@ -2345,60 +2344,3 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet):
     class Meta:
         model = Interface
         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)'),
-    )

+ 4 - 4
netbox/dcim/graphql/types.py

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

+ 0 - 4
netbox/dcim/models/mixins.py

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

+ 3 - 2
netbox/ipam/api/serializers_/ip.py

@@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
+from dcim.constants import LOCATION_SCOPE_TYPES
 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 netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.serializers import NetBoxModelSerializer
@@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer):
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
-            model__in=PREFIX_SCOPE_TYPES
+            model__in=LOCATION_SCOPE_TYPES
         ),
         allow_null=True,
         required=False,

+ 1 - 1
netbox/ipam/apps.py

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

+ 0 - 5
netbox/ipam/constants.py

@@ -23,11 +23,6 @@ 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

+ 3 - 53
netbox/ipam/filtersets.py

@@ -1,5 +1,6 @@
 import django_filters
 import netaddr
+from dcim.base_filtersets import ScopedFilterSet
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db.models import Q
@@ -9,7 +10,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, Location, Region, Site, SiteGroup
+from dcim.models import Device, Interface, Region, Site, SiteGroup
 from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import (
@@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
         fields = ('id', 'name', 'slug', 'description', 'weight')
 
 
-class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
     family = django_filters.NumberFilter(
         field_name='prefix',
         lookup_expr='family'
@@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         to_field_name='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(
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),

+ 2 - 28
netbox/ipam/forms/bulk_edit.py

@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
+from dcim.forms.mixins import ScopedBulkEditForm
 from dcim.models import Region, Site, SiteGroup
 from ipam.choices import *
 from ipam.constants import *
@@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('description',)
 
 
-class PrefixBulkEditForm(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
-    )
+class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
     vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         required=False,
@@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
         '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):
     vrf = DynamicModelChoiceField(

+ 3 - 7
netbox/ipam/forms/bulk_import.py

@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface, Site
+from dcim.forms.mixins import ScopedImportForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.models import *
@@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm):
         fields = ('name', 'slug', 'weight', 'description', 'tags')
 
 
-class PrefixImportForm(NetBoxModelImportForm):
+class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
     vrf = CSVModelChoiceField(
         label=_('VRF'),
         queryset=VRF.objects.all(),
@@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm):
         to_field_name='name',
         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(
         label=_('VLAN group'),
         queryset=VLANGroup.objects.all(),
@@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm):
             'mark_utilized', 'description', 'comments', 'tags',
         )
         labels = {
-            'scope_id': 'Scope ID',
+            'scope_id': _('Scope ID'),
         }
 
     def __init__(self, data=None, *args, **kwargs):

+ 2 - 44
netbox/ipam/forms/model_forms.py

@@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device, Interface, Site
+from dcim.forms.mixins import ScopedForm
 from ipam.choices import *
 from ipam.constants import *
 from ipam.formfields import IPNetworkFormField
@@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm):
         ]
 
 
-class PrefixForm(TenancyForm, NetBoxModelForm):
+class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
         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(
         queryset=VLAN.objects.all(),
         required=False,
@@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
             '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):
     vrf = DynamicModelChoiceField(

+ 1 - 1
netbox/ipam/graphql/types.py

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

+ 7 - 7
netbox/ipam/migrations/0072_prefix_cached_relations.py

@@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor):
     prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
     for prefix in prefixes:
         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
         # 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):
@@ -29,22 +29,22 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='prefix',
             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(
             model_name='prefix',
             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(
             model_name='prefix',
             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(
             model_name='prefix',
-            name='_sitegroup',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'),
+            name='_site_group',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'),
         ),
 
         # Populate denormalized FK values

+ 2 - 67
netbox/ipam/models/ip.py

@@ -1,5 +1,4 @@
 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
@@ -9,6 +8,7 @@ from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
 from core.models import ObjectType
+from dcim.models.mixins import CachedScopeMixin
 from ipam.choices import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
@@ -198,7 +198,7 @@ class Role(OrganizationalModel):
         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
     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'),
         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(
         to='ipam.VRF',
         on_delete=models.PROTECT,
@@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
         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
     _depth = models.PositiveSmallIntegerField(
         default=0,
@@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
 
         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
     def family(self):
         return self.prefix.version if self.prefix else None

+ 1 - 1
netbox/utilities/api.py

@@ -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():
         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)
 
     return annotations

+ 2 - 1
netbox/virtualization/filtersets.py

@@ -2,7 +2,8 @@ import django_filters
 from django.db.models import Q
 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 extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate

+ 41 - 0
netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py

@@ -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'
+            ),
+        ),
+    ]

+ 1 - 1
netbox/virtualization/migrations/0046_natural_ordering.py → netbox/virtualization/migrations/0047_natural_ordering.py

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

+ 1 - 1
netbox/wireless/filtersets.py

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

+ 41 - 0
netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py

@@ -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'
+            ),
+        ),
+    ]

+ 1 - 1
netbox/wireless/migrations/0012_natural_ordering.py → netbox/wireless/migrations/0013_natural_ordering.py

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