Просмотр исходного кода

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 год назад
Родитель
Сommit
9fe6685562

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

@@ -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

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

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

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

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

@@ -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
     )
     )

+ 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 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,

+ 1 - 1
netbox/ipam/apps.py

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

+ 0 - 5
netbox/ipam/constants.py

@@ -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

+ 3 - 53
netbox/ipam/filtersets.py

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

+ 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.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):
-    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(
     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(

+ 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 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):

+ 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 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(

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

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

+ 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')
     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',
-            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
         # Populate denormalized FK values

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

@@ -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

+ 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():
     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

+ 2 - 1
netbox/virtualization/filtersets.py

@@ -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

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

+ 1 - 1
netbox/wireless/filtersets.py

@@ -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

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