diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 10cd06563..9eeb0f588 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), queryset=Provider.objects.all(), - selector=True + selector=True, + quick_add=True ) provider_account = DynamicModelChoiceField( label=_('Provider account'), @@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all() + queryset=CircuitType.objects.all(), + quick_add=True ) comments = CommentField() diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 7cd89e38c..b818cd954 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/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 diff --git a/netbox/dcim/base_filtersets.py b/netbox/dcim/base_filtersets.py new file mode 100644 index 000000000..c007c0120 --- /dev/null +++ b/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)'), + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 73631e50b..cb8d91c89 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -74,7 +74,6 @@ __all__ = ( 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', - 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2441,60 +2440,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)'), - ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 3e6a99e45..876bd9d3d 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -113,12 +113,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( label=_('Region'), queryset=Region.objects.all(), - required=False + required=False, + quick_add=True ) group = DynamicModelChoiceField( label=_('Group'), queryset=SiteGroup.objects.all(), - required=False + required=False, + quick_add=True ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), @@ -207,7 +209,8 @@ class RackRoleForm(NetBoxModelForm): class RackTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) comments = CommentField() slug = SlugField( @@ -349,7 +352,8 @@ class ManufacturerForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) default_platform = DynamicModelChoiceField( label=_('Default platform'), @@ -437,7 +441,8 @@ class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), - required=False + required=False, + quick_add=True ) config_template = DynamicModelChoiceField( label=_('Config template'), @@ -509,7 +514,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) role = DynamicModelChoiceField( label=_('Device role'), - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + quick_add=True ) platform = DynamicModelChoiceField( label=_('Platform'), @@ -751,7 +757,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): power_panel = DynamicModelChoiceField( label=_('Power panel'), queryset=PowerPanel.objects.all(), - selector=True + selector=True, + quick_add=True ) rack = DynamicModelChoiceField( label=_('Rack'), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 7e51790e2..7aa4ef8a0 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -479,7 +479,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')]]: @@ -725,7 +725,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')]]: @@ -757,7 +757,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')]]: @@ -781,7 +781,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')]]: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 1df3364c4..ac4d7dab9 100644 --- a/netbox/dcim/models/mixins.py +++ b/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 ) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 0c3c141af..bfc7ac546 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/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, diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index e0463dfce..ae88d69a9 100644 --- a/netbox/ipam/apps.py +++ b/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', diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index c07b8441f..6dffd3287 100644 --- a/netbox/ipam/constants.py +++ b/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 diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88c869a50..c762c15fe 100644 --- a/netbox/ipam/filtersets.py +++ b/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)'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index c323a41c1..7f3216cfd 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3be4ccc59..7e1382be9 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/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): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3d0cd3dd1..53ffe8f3f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/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 @@ -108,7 +109,8 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label=_('RIR') + label=_('RIR'), + quick_add=True ) comments = CommentField() @@ -131,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) slug = SlugField() fieldsets = ( @@ -149,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -197,25 +201,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, @@ -228,7 +219,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -248,36 +240,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( @@ -288,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -681,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) qinq_svlan = DynamicModelChoiceField( label=_('Q-in-Q SVLAN'), diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 2ef63cf0c..5a4813e0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/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): diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 2b457ebda..4b438f7d5 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/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 diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b17e26169..dcecbcdea 100644 --- a/netbox/ipam/models/ip.py +++ b/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 diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0686e52b7..fb554ca4f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): form = self.form(instance=obj, initial=initial_data) restrict_form_fields(form, request.user) - # If this is an HTMX request, return only the rendered form HTML - if htmx_partial(request): - return render(request, self.htmx_template_name, { - 'model': model, - 'object': obj, - 'form': form, - }) - - return render(request, self.template_name, { + context = { 'model': model, 'object': obj, 'form': form, + } + + # If the form is being displayed within a "quick add" widget, + # use the appropriate template + if request.GET.get('_quickadd'): + return render(request, 'htmx/quick_add.html', context) + + # If this is an HTMX request, return only the rendered form HTML + if htmx_partial(request): + return render(request, self.htmx_template_name, context) + + return render(request, self.template_name, { + **context, 'return_url': self.get_return_url(request, obj), 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request, obj), @@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + model = self.queryset.model # Take a snapshot for change logging (if editing an existing object) if obj.pk and hasattr(obj, 'snapshot'): @@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): msg = f'{msg} {obj}' messages.success(request, msg) + # Object was created via "quick add" modal + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add_created.html', { + 'object': obj, + }) + # If adding another object, redirect back to the edit form if '_addanother' in request.POST: redirect_url = request.path @@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): else: logger.debug("Form validation failed") - return render(request, self.template_name, { + context = { + 'model': model, 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), **self.get_extra_context(request, obj), - }) + } + + # Form was submitted via a "quick add" widget + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add.html', context) + + return render(request, self.template_name, context) class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 969d5c73a..5e24ee625 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0f4ac63d3..a05e8edb5 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/reslug.ts b/netbox/project-static/src/buttons/reslug.ts index f445854c1..53c21b1f0 100644 --- a/netbox/project-static/src/buttons/reslug.ts +++ b/netbox/project-static/src/buttons/reslug.ts @@ -1,3 +1,5 @@ +import { getElements } from '../util'; + /** * Create a slug from any input string. * @@ -15,34 +17,30 @@ function slugify(slug: string, chars: number): string { } /** - * If a slug field exists, add event listeners to handle automatically generating its value. + * For any slug fields, add event listeners to handle automatically generating slug values. */ export function initReslug(): void { - const slugField = document.getElementById('id_slug') as HTMLInputElement; - const slugButton = document.getElementById('reslug') as HTMLButtonElement; - if (slugField === null || slugButton === null) { - return; - } - const sourceId = slugField.getAttribute('slug-source'); - const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement; + for (const slugButton of getElements('button#reslug')) { + const form = slugButton.form; + if (form == null) continue; + const slugField = form.querySelector('#id_slug') as HTMLInputElement; + if (slugField == null) continue; + const sourceId = slugField.getAttribute('slug-source'); + const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement; - if (sourceField === null) { - console.error('Unable to find field for slug field.'); - return; - } + const slugLengthAttr = slugField.getAttribute('maxlength'); + let slugLength = 50; - const slugLengthAttr = slugField.getAttribute('maxlength'); - let slugLength = 50; - - if (slugLengthAttr) { - slugLength = Number(slugLengthAttr); - } - sourceField.addEventListener('blur', () => { - if (!slugField.value) { - slugField.value = slugify(sourceField.value, slugLength); + if (slugLengthAttr) { + slugLength = Number(slugLengthAttr); } - }); - slugButton.addEventListener('click', () => { - slugField.value = slugify(sourceField.value, slugLength); - }); + sourceField.addEventListener('blur', () => { + if (!slugField.value) { + slugField.value = slugify(sourceField.value, slugLength); + } + }); + slugButton.addEventListener('click', () => { + slugField.value = slugify(sourceField.value, slugLength); + }); + } } diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index f4092036b..6a772011b 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -4,11 +4,16 @@ import { initSelects } from './select'; import { initObjectSelector } from './objectSelector'; import { initBootstrap } from './bs'; import { initMessages } from './messages'; +import { initQuickAdd } from './quickAdd'; function initDepedencies(): void { - for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) { - init(); - } + initButtons(); + initClipboard(); + initSelects(); + initObjectSelector(); + initQuickAdd(); + initBootstrap(); + initMessages(); } /** diff --git a/netbox/project-static/src/quickAdd.ts b/netbox/project-static/src/quickAdd.ts new file mode 100644 index 000000000..e038f5d19 --- /dev/null +++ b/netbox/project-static/src/quickAdd.ts @@ -0,0 +1,39 @@ +import { Modal } from 'bootstrap'; + +function handleQuickAddObject(): void { + const quick_add = document.getElementById('quick-add-object'); + if (quick_add == null) return; + + const object_id = quick_add.getAttribute('data-object-id'); + if (object_id == null) return; + const object_repr = quick_add.getAttribute('data-object-repr'); + if (object_repr == null) return; + + const target_id = quick_add.getAttribute('data-target-id'); + if (target_id == null) return; + const target = document.getElementById(target_id); + if (target == null) return; + + //@ts-expect-error tomselect added on init + target.tomselect.addOption({ + id: object_id, + display: object_repr, + }); + //@ts-expect-error tomselect added on init + target.tomselect.addItem(object_id); + + const modal_element = document.getElementById('htmx-modal'); + if (modal_element) { + const modal = Modal.getInstance(modal_element); + if (modal) { + modal.hide(); + } + } +} + +export function initQuickAdd(): void { + const quick_add_modal = document.getElementById('htmx-modal-content'); + if (quick_add_modal) { + quick_add_modal.addEventListener('htmx:afterSwap', () => handleQuickAddObject()); + } +} diff --git a/netbox/templates/htmx/quick_add.html b/netbox/templates/htmx/quick_add.html new file mode 100644 index 000000000..9473e14a1 --- /dev/null +++ b/netbox/templates/htmx/quick_add.html @@ -0,0 +1,28 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/templates/htmx/quick_add_created.html b/netbox/templates/htmx/quick_add_created.html new file mode 100644 index 000000000..3b1a24c48 --- /dev/null +++ b/netbox/templates/htmx/quick_add_created.html @@ -0,0 +1,22 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 114253e7a..0edb36348 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -25,6 +25,7 @@ class TenancyForm(forms.Form): label=_('Tenant'), queryset=Tenant.objects.all(), required=False, + quick_add=True, query_params={ 'group_id': '$tenant_group' } diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 11b914811..6793c0526 100644 --- a/netbox/utilities/api.py +++ b/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 diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index 6666c0e4d..13d5ffc70 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -2,7 +2,7 @@ import django_filters from django import forms from django.conf import settings from django.forms import BoundField -from django.urls import reverse +from django.urls import reverse, reverse_lazy from utilities.forms import widgets from utilities.views import get_viewname @@ -66,6 +66,8 @@ class DynamicModelChoiceMixin: choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead) context: A mapping of