mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-29 11:56:25 -06:00
Merge branch 'feature' into 4867-multiple-mac-addresses
This commit is contained in:
commit
b3e4703df8
@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm):
|
|||||||
class ProviderAccountForm(NetBoxModelForm):
|
class ProviderAccountForm(NetBoxModelForm):
|
||||||
provider = DynamicModelChoiceField(
|
provider = DynamicModelChoiceField(
|
||||||
label=_('Provider'),
|
label=_('Provider'),
|
||||||
queryset=Provider.objects.all()
|
queryset=Provider.objects.all(),
|
||||||
|
selector=True,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm):
|
|||||||
class ProviderNetworkForm(NetBoxModelForm):
|
class ProviderNetworkForm(NetBoxModelForm):
|
||||||
provider = DynamicModelChoiceField(
|
provider = DynamicModelChoiceField(
|
||||||
label=_('Provider'),
|
label=_('Provider'),
|
||||||
queryset=Provider.objects.all()
|
queryset=Provider.objects.all(),
|
||||||
|
selector=True,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
provider = DynamicModelChoiceField(
|
provider = DynamicModelChoiceField(
|
||||||
label=_('Provider'),
|
label=_('Provider'),
|
||||||
queryset=Provider.objects.all(),
|
queryset=Provider.objects.all(),
|
||||||
selector=True
|
selector=True,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
provider_account = DynamicModelChoiceField(
|
provider_account = DynamicModelChoiceField(
|
||||||
label=_('Provider account'),
|
label=_('Provider account'),
|
||||||
@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
type = DynamicModelChoiceField(
|
type = DynamicModelChoiceField(
|
||||||
queryset=CircuitType.objects.all()
|
queryset=CircuitType.objects.all(),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
|
@ -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
netbox/dcim/base_filtersets.py
Normal file
67
netbox/dcim/base_filtersets.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import django_filters
|
||||||
|
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from netbox.filtersets import BaseFilterSet
|
||||||
|
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||||
|
from .models import *
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'ScopedFilterSet',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedFilterSet(BaseFilterSet):
|
||||||
|
"""
|
||||||
|
Provides additional filtering functionality for location, site, etc.. for Scoped models.
|
||||||
|
"""
|
||||||
|
scope_type = ContentTypeFilter()
|
||||||
|
region_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Region.objects.all(),
|
||||||
|
field_name='_region',
|
||||||
|
lookup_expr='in',
|
||||||
|
label=_('Region (ID)'),
|
||||||
|
)
|
||||||
|
region = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Region.objects.all(),
|
||||||
|
field_name='_region',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Region (slug)'),
|
||||||
|
)
|
||||||
|
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=SiteGroup.objects.all(),
|
||||||
|
field_name='_site_group',
|
||||||
|
lookup_expr='in',
|
||||||
|
label=_('Site group (ID)'),
|
||||||
|
)
|
||||||
|
site_group = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=SiteGroup.objects.all(),
|
||||||
|
field_name='_site_group',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Site group (slug)'),
|
||||||
|
)
|
||||||
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
field_name='_site',
|
||||||
|
label=_('Site (ID)'),
|
||||||
|
)
|
||||||
|
site = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='_site__slug',
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Site (slug)'),
|
||||||
|
)
|
||||||
|
location_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
field_name='_location',
|
||||||
|
lookup_expr='in',
|
||||||
|
label=_('Location (ID)'),
|
||||||
|
)
|
||||||
|
location = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=Location.objects.all(),
|
||||||
|
field_name='_location',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Location (slug)'),
|
||||||
|
)
|
@ -74,7 +74,6 @@ __all__ = (
|
|||||||
'RearPortFilterSet',
|
'RearPortFilterSet',
|
||||||
'RearPortTemplateFilterSet',
|
'RearPortTemplateFilterSet',
|
||||||
'RegionFilterSet',
|
'RegionFilterSet',
|
||||||
'ScopedFilterSet',
|
|
||||||
'SiteFilterSet',
|
'SiteFilterSet',
|
||||||
'SiteGroupFilterSet',
|
'SiteGroupFilterSet',
|
||||||
'VirtualChassisFilterSet',
|
'VirtualChassisFilterSet',
|
||||||
@ -2441,60 +2440,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)'),
|
|
||||||
)
|
|
||||||
|
@ -113,12 +113,14 @@ class SiteForm(TenancyForm, NetBoxModelForm):
|
|||||||
region = DynamicModelChoiceField(
|
region = DynamicModelChoiceField(
|
||||||
label=_('Region'),
|
label=_('Region'),
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
label=_('Group'),
|
label=_('Group'),
|
||||||
queryset=SiteGroup.objects.all(),
|
queryset=SiteGroup.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
asns = DynamicModelMultipleChoiceField(
|
asns = DynamicModelMultipleChoiceField(
|
||||||
queryset=ASN.objects.all(),
|
queryset=ASN.objects.all(),
|
||||||
@ -207,7 +209,8 @@ class RackRoleForm(NetBoxModelForm):
|
|||||||
class RackTypeForm(NetBoxModelForm):
|
class RackTypeForm(NetBoxModelForm):
|
||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all()
|
queryset=Manufacturer.objects.all(),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
slug = SlugField(
|
slug = SlugField(
|
||||||
@ -349,7 +352,8 @@ class ManufacturerForm(NetBoxModelForm):
|
|||||||
class DeviceTypeForm(NetBoxModelForm):
|
class DeviceTypeForm(NetBoxModelForm):
|
||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all()
|
queryset=Manufacturer.objects.all(),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
default_platform = DynamicModelChoiceField(
|
default_platform = DynamicModelChoiceField(
|
||||||
label=_('Default platform'),
|
label=_('Default platform'),
|
||||||
@ -437,7 +441,8 @@ class PlatformForm(NetBoxModelForm):
|
|||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
config_template = DynamicModelChoiceField(
|
config_template = DynamicModelChoiceField(
|
||||||
label=_('Config template'),
|
label=_('Config template'),
|
||||||
@ -509,7 +514,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
label=_('Device role'),
|
label=_('Device role'),
|
||||||
queryset=DeviceRole.objects.all()
|
queryset=DeviceRole.objects.all(),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
platform = DynamicModelChoiceField(
|
platform = DynamicModelChoiceField(
|
||||||
label=_('Platform'),
|
label=_('Platform'),
|
||||||
@ -751,7 +757,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm):
|
|||||||
power_panel = DynamicModelChoiceField(
|
power_panel = DynamicModelChoiceField(
|
||||||
label=_('Power panel'),
|
label=_('Power panel'),
|
||||||
queryset=PowerPanel.objects.all(),
|
queryset=PowerPanel.objects.all(),
|
||||||
selector=True
|
selector=True,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
rack = DynamicModelChoiceField(
|
rack = DynamicModelChoiceField(
|
||||||
label=_('Rack'),
|
label=_('Rack'),
|
||||||
|
@ -479,7 +479,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')]]:
|
||||||
@ -725,7 +725,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')]]:
|
||||||
@ -757,7 +757,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')]]:
|
||||||
@ -781,7 +781,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')]]:
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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)'),
|
||||||
|
@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from dcim.forms.mixins import ScopedBulkEditForm
|
||||||
from dcim.models import Region, Site, SiteGroup
|
from dcim.models import Region, Site, SiteGroup
|
||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
nullable_fields = ('description',)
|
nullable_fields = ('description',)
|
||||||
|
|
||||||
|
|
||||||
class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
|
||||||
scope_type = ContentTypeChoiceField(
|
|
||||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
|
||||||
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
|
|
||||||
required=False,
|
|
||||||
label=_('Scope type')
|
|
||||||
)
|
|
||||||
scope = DynamicModelChoiceField(
|
|
||||||
label=_('Scope'),
|
|
||||||
queryset=Site.objects.none(), # Initial queryset
|
|
||||||
required=False,
|
|
||||||
disabled=True,
|
|
||||||
selector=True
|
|
||||||
)
|
|
||||||
vlan_group = DynamicModelChoiceField(
|
vlan_group = DynamicModelChoiceField(
|
||||||
queryset=VLANGroup.objects.all(),
|
queryset=VLANGroup.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
|
'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
if scope_type_id := get_field_value(self, 'scope_type'):
|
|
||||||
try:
|
|
||||||
scope_type = ContentType.objects.get(pk=scope_type_id)
|
|
||||||
model = scope_type.model_class()
|
|
||||||
self.fields['scope'].queryset = model.objects.all()
|
|
||||||
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
|
|
||||||
self.fields['scope'].disabled = False
|
|
||||||
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
@ -108,7 +109,8 @@ class RIRForm(NetBoxModelForm):
|
|||||||
class AggregateForm(TenancyForm, NetBoxModelForm):
|
class AggregateForm(TenancyForm, NetBoxModelForm):
|
||||||
rir = DynamicModelChoiceField(
|
rir = DynamicModelChoiceField(
|
||||||
queryset=RIR.objects.all(),
|
queryset=RIR.objects.all(),
|
||||||
label=_('RIR')
|
label=_('RIR'),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -131,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm):
|
|||||||
rir = DynamicModelChoiceField(
|
rir = DynamicModelChoiceField(
|
||||||
queryset=RIR.objects.all(),
|
queryset=RIR.objects.all(),
|
||||||
label=_('RIR'),
|
label=_('RIR'),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@ -149,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm):
|
|||||||
rir = DynamicModelChoiceField(
|
rir = DynamicModelChoiceField(
|
||||||
queryset=RIR.objects.all(),
|
queryset=RIR.objects.all(),
|
||||||
label=_('RIR'),
|
label=_('RIR'),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
sites = DynamicModelMultipleChoiceField(
|
sites = DynamicModelMultipleChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
@ -197,25 +201,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,
|
||||||
@ -228,7 +219,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
|||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
label=_('Role'),
|
label=_('Role'),
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -248,36 +240,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(
|
||||||
@ -288,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
|||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
label=_('Role'),
|
label=_('Role'),
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
@ -681,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm):
|
|||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
label=_('Role'),
|
label=_('Role'),
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
qinq_svlan = DynamicModelChoiceField(
|
qinq_svlan = DynamicModelChoiceField(
|
||||||
label=_('Q-in-Q SVLAN'),
|
label=_('Q-in-Q SVLAN'),
|
||||||
|
@ -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):
|
||||||
|
@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor):
|
|||||||
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
|
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
|
||||||
for prefix in prefixes:
|
for prefix in prefixes:
|
||||||
prefix._region_id = prefix.site.region_id
|
prefix._region_id = prefix.site.region_id
|
||||||
prefix._sitegroup_id = prefix.site.group_id
|
prefix._site_group_id = prefix.site.group_id
|
||||||
prefix._site_id = prefix.site_id
|
prefix._site_id = prefix.site_id
|
||||||
# Note: Location cannot be set prior to migration
|
# Note: Location cannot be set prior to migration
|
||||||
|
|
||||||
Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site'])
|
Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site'])
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -29,22 +29,22 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='prefix',
|
model_name='prefix',
|
||||||
name='_location',
|
name='_location',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='prefix',
|
model_name='prefix',
|
||||||
name='_region',
|
name='_region',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='prefix',
|
model_name='prefix',
|
||||||
name='_site',
|
name='_site',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='prefix',
|
model_name='prefix',
|
||||||
name='_sitegroup',
|
name='_site_group',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup'),
|
||||||
),
|
),
|
||||||
|
|
||||||
# Populate denormalized FK values
|
# Populate denormalized FK values
|
||||||
|
@ -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
|
||||||
|
@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
form = self.form(instance=obj, initial=initial_data)
|
form = self.form(instance=obj, initial=initial_data)
|
||||||
restrict_form_fields(form, request.user)
|
restrict_form_fields(form, request.user)
|
||||||
|
|
||||||
# If this is an HTMX request, return only the rendered form HTML
|
context = {
|
||||||
if htmx_partial(request):
|
|
||||||
return render(request, self.htmx_template_name, {
|
|
||||||
'model': model,
|
|
||||||
'object': obj,
|
|
||||||
'form': form,
|
|
||||||
})
|
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
|
||||||
'model': model,
|
'model': model,
|
||||||
'object': obj,
|
'object': obj,
|
||||||
'form': form,
|
'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),
|
'return_url': self.get_return_url(request, obj),
|
||||||
'prerequisite_model': get_prerequisite_model(self.queryset),
|
'prerequisite_model': get_prerequisite_model(self.queryset),
|
||||||
**self.get_extra_context(request, obj),
|
**self.get_extra_context(request, obj),
|
||||||
@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
"""
|
"""
|
||||||
logger = logging.getLogger('netbox.views.ObjectEditView')
|
logger = logging.getLogger('netbox.views.ObjectEditView')
|
||||||
obj = self.get_object(**kwargs)
|
obj = self.get_object(**kwargs)
|
||||||
|
model = self.queryset.model
|
||||||
|
|
||||||
# Take a snapshot for change logging (if editing an existing object)
|
# Take a snapshot for change logging (if editing an existing object)
|
||||||
if obj.pk and hasattr(obj, 'snapshot'):
|
if obj.pk and hasattr(obj, 'snapshot'):
|
||||||
@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
msg = f'{msg} {obj}'
|
msg = f'{msg} {obj}'
|
||||||
messages.success(request, msg)
|
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 adding another object, redirect back to the edit form
|
||||||
if '_addanother' in request.POST:
|
if '_addanother' in request.POST:
|
||||||
redirect_url = request.path
|
redirect_url = request.path
|
||||||
@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Form validation failed")
|
logger.debug("Form validation failed")
|
||||||
|
|
||||||
return render(request, self.template_name, {
|
context = {
|
||||||
|
'model': model,
|
||||||
'object': obj,
|
'object': obj,
|
||||||
'form': form,
|
'form': form,
|
||||||
'return_url': self.get_return_url(request, obj),
|
'return_url': self.get_return_url(request, obj),
|
||||||
**self.get_extra_context(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):
|
class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
||||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -1,3 +1,5 @@
|
|||||||
|
import { getElements } from '../util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a slug from any input string.
|
* 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 {
|
export function initReslug(): void {
|
||||||
const slugField = document.getElementById('id_slug') as HTMLInputElement;
|
for (const slugButton of getElements<HTMLButtonElement>('button#reslug')) {
|
||||||
const slugButton = document.getElementById('reslug') as HTMLButtonElement;
|
const form = slugButton.form;
|
||||||
if (slugField === null || slugButton === null) {
|
if (form == null) continue;
|
||||||
return;
|
const slugField = form.querySelector('#id_slug') as HTMLInputElement;
|
||||||
}
|
if (slugField == null) continue;
|
||||||
const sourceId = slugField.getAttribute('slug-source');
|
const sourceId = slugField.getAttribute('slug-source');
|
||||||
const sourceField = document.getElementById(`id_${sourceId}`) as HTMLInputElement;
|
const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement;
|
||||||
|
|
||||||
if (sourceField === null) {
|
const slugLengthAttr = slugField.getAttribute('maxlength');
|
||||||
console.error('Unable to find field for slug field.');
|
let slugLength = 50;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const slugLengthAttr = slugField.getAttribute('maxlength');
|
if (slugLengthAttr) {
|
||||||
let slugLength = 50;
|
slugLength = Number(slugLengthAttr);
|
||||||
|
|
||||||
if (slugLengthAttr) {
|
|
||||||
slugLength = Number(slugLengthAttr);
|
|
||||||
}
|
|
||||||
sourceField.addEventListener('blur', () => {
|
|
||||||
if (!slugField.value) {
|
|
||||||
slugField.value = slugify(sourceField.value, slugLength);
|
|
||||||
}
|
}
|
||||||
});
|
sourceField.addEventListener('blur', () => {
|
||||||
slugButton.addEventListener('click', () => {
|
if (!slugField.value) {
|
||||||
slugField.value = slugify(sourceField.value, slugLength);
|
slugField.value = slugify(sourceField.value, slugLength);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
slugButton.addEventListener('click', () => {
|
||||||
|
slugField.value = slugify(sourceField.value, slugLength);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,11 +4,16 @@ import { initSelects } from './select';
|
|||||||
import { initObjectSelector } from './objectSelector';
|
import { initObjectSelector } from './objectSelector';
|
||||||
import { initBootstrap } from './bs';
|
import { initBootstrap } from './bs';
|
||||||
import { initMessages } from './messages';
|
import { initMessages } from './messages';
|
||||||
|
import { initQuickAdd } from './quickAdd';
|
||||||
|
|
||||||
function initDepedencies(): void {
|
function initDepedencies(): void {
|
||||||
for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) {
|
initButtons();
|
||||||
init();
|
initClipboard();
|
||||||
}
|
initSelects();
|
||||||
|
initObjectSelector();
|
||||||
|
initQuickAdd();
|
||||||
|
initBootstrap();
|
||||||
|
initMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
39
netbox/project-static/src/quickAdd.ts
Normal file
39
netbox/project-static/src/quickAdd.ts
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
28
netbox/templates/htmx/quick_add.html
Normal file
28
netbox/templates/htmx/quick_add.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{% load form_helpers %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">
|
||||||
|
{% trans "Quick Add" %} {{ model|meta:"verbose_name"|bettertitle }}
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body row">
|
||||||
|
<form
|
||||||
|
hx-post="{% url model|viewname:"add" %}?_quickadd=True&target={{ request.GET.target }}"
|
||||||
|
hx-target="#htmx-modal-content"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% include 'htmx/form.html' %}
|
||||||
|
<div class="text-end">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-float" data-bs-dismiss="modal" aria-label="Cancel">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="_quickadd" class="btn btn-primary">
|
||||||
|
{% trans "Create" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
22
netbox/templates/htmx/quick_add_created.html
Normal file
22
netbox/templates/htmx/quick_add_created.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% load form_helpers %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title">
|
||||||
|
{{ object|meta:"verbose_name"|bettertitle }} {% trans "Created" %}
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body row">
|
||||||
|
{# This content is intended to be scraped and populated in the targeted selection field. #}
|
||||||
|
<p id="quick-add-object"
|
||||||
|
data-object-repr="{{ object }}"
|
||||||
|
data-object-id="{{ object.pk }}"
|
||||||
|
data-target-id="{{ request.GET.target }}"
|
||||||
|
>
|
||||||
|
{% blocktrans with object=object|linkify object_type=object|meta:"verbose_name" %}
|
||||||
|
Created {{ object_type }} {{ object }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
@ -25,6 +25,7 @@ class TenancyForm(forms.Form):
|
|||||||
label=_('Tenant'),
|
label=_('Tenant'),
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
quick_add=True,
|
||||||
query_params={
|
query_params={
|
||||||
'group_id': '$tenant_group'
|
'group_id': '$tenant_group'
|
||||||
}
|
}
|
||||||
|
@ -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,7 +2,7 @@ import django_filters
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.forms import BoundField
|
from django.forms import BoundField
|
||||||
from django.urls import reverse
|
from django.urls import reverse, reverse_lazy
|
||||||
|
|
||||||
from utilities.forms import widgets
|
from utilities.forms import widgets
|
||||||
from utilities.views import get_viewname
|
from utilities.views import get_viewname
|
||||||
@ -66,6 +66,8 @@ class DynamicModelChoiceMixin:
|
|||||||
choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
|
choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead)
|
||||||
context: A mapping of <option> template variables to their API data keys (optional; see below)
|
context: A mapping of <option> template variables to their API data keys (optional; see below)
|
||||||
selector: Include an advanced object selection widget to assist the user in identifying the desired object
|
selector: Include an advanced object selection widget to assist the user in identifying the desired object
|
||||||
|
quick_add: Include a widget to quickly create a new related object for assignment. NOTE: Nested usage of
|
||||||
|
quick-add fields is not currently supported.
|
||||||
|
|
||||||
Context keys:
|
Context keys:
|
||||||
value: The name of the attribute which contains the option's value (default: 'id')
|
value: The name of the attribute which contains the option's value (default: 'id')
|
||||||
@ -90,6 +92,7 @@ class DynamicModelChoiceMixin:
|
|||||||
disabled_indicator=None,
|
disabled_indicator=None,
|
||||||
context=None,
|
context=None,
|
||||||
selector=False,
|
selector=False,
|
||||||
|
quick_add=False,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
self.model = queryset.model
|
self.model = queryset.model
|
||||||
@ -99,6 +102,7 @@ class DynamicModelChoiceMixin:
|
|||||||
self.disabled_indicator = disabled_indicator
|
self.disabled_indicator = disabled_indicator
|
||||||
self.context = context or {}
|
self.context = context or {}
|
||||||
self.selector = selector
|
self.selector = selector
|
||||||
|
self.quick_add = quick_add
|
||||||
|
|
||||||
super().__init__(queryset, **kwargs)
|
super().__init__(queryset, **kwargs)
|
||||||
|
|
||||||
@ -121,6 +125,12 @@ class DynamicModelChoiceMixin:
|
|||||||
if self.selector:
|
if self.selector:
|
||||||
attrs['selector'] = self.model._meta.label_lower
|
attrs['selector'] = self.model._meta.label_lower
|
||||||
|
|
||||||
|
# Include quick add?
|
||||||
|
if self.quick_add:
|
||||||
|
app_label = self.model._meta.app_label
|
||||||
|
model_name = self.model._meta.model_name
|
||||||
|
attrs['quick_add'] = reverse_lazy(f'{app_label}:{model_name}_add')
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def get_bound_field(self, form, field_name):
|
def get_bound_field(self, form, field_name):
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% if widget.attrs.selector and not widget.attrs.disabled %}
|
<div class="d-flex">
|
||||||
<div class="d-flex">
|
{% include 'django/forms/widgets/select.html' %}
|
||||||
{% include 'django/forms/widgets/select.html' %}
|
{% if widget.attrs.selector and not widget.attrs.disabled %}
|
||||||
|
{# Opens the object selector modal #}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="{% trans "Open selector" %}"
|
title="{% trans "Open selector" %}"
|
||||||
@ -13,7 +14,19 @@
|
|||||||
>
|
>
|
||||||
<i class="mdi mdi-database-search-outline"></i>
|
<i class="mdi mdi-database-search-outline"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{% endif %}
|
||||||
{% else %}
|
{% if widget.attrs.quick_add and not widget.attrs.disabled %}
|
||||||
{% include 'django/forms/widgets/select.html' %}
|
{# Opens the quick add modal #}
|
||||||
{% endif %}
|
<button
|
||||||
|
type="button"
|
||||||
|
title="{% trans "Quick add" %}"
|
||||||
|
class="btn btn-outline-secondary ms-1"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#htmx-modal"
|
||||||
|
hx-get="{{ widget.attrs.quick_add }}?_quickadd=True&target={{ widget.attrs.id }}"
|
||||||
|
hx-target="#htmx-modal-content"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-plus-circle"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
@ -2,8 +2,10 @@ 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.base_filtersets import ScopedFilterSet
|
||||||
from dcim.models import Device, DeviceRole, MACAddress, Platform, Region, Site, SiteGroup
|
from dcim.filtersets import CommonInterfaceFilterSet
|
||||||
|
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||||
|
from dcim.models import MACAddress
|
||||||
from extras.filtersets import LocalConfigContextFilterSet
|
from extras.filtersets import LocalConfigContextFilterSet
|
||||||
from extras.models import ConfigTemplate
|
from extras.models import ConfigTemplate
|
||||||
from ipam.filtersets import PrimaryIPFilterSet
|
from ipam.filtersets import PrimaryIPFilterSet
|
||||||
|
@ -62,12 +62,14 @@ class ClusterGroupForm(NetBoxModelForm):
|
|||||||
class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||||
type = DynamicModelChoiceField(
|
type = DynamicModelChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
queryset=ClusterType.objects.all()
|
queryset=ClusterType.objects.all(),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
label=_('Group'),
|
label=_('Group'),
|
||||||
queryset=ClusterGroup.objects.all(),
|
queryset=ClusterGroup.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
@ -27,7 +27,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0199_macaddress'),
|
('dcim', '0199_macaddress'),
|
||||||
('virtualization', '0046_natural_ordering'),
|
('virtualization', '0047_natural_ordering'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -47,7 +47,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
|
|||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
queryset=TunnelGroup.objects.all(),
|
queryset=TunnelGroup.objects.all(),
|
||||||
label=_('Tunnel Group'),
|
label=_('Tunnel Group'),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
ipsec_profile = DynamicModelChoiceField(
|
ipsec_profile = DynamicModelChoiceField(
|
||||||
queryset=IPSecProfile.objects.all(),
|
queryset=IPSecProfile.objects.all(),
|
||||||
@ -313,7 +314,8 @@ class IKEProposalForm(NetBoxModelForm):
|
|||||||
class IKEPolicyForm(NetBoxModelForm):
|
class IKEPolicyForm(NetBoxModelForm):
|
||||||
proposals = DynamicModelMultipleChoiceField(
|
proposals = DynamicModelMultipleChoiceField(
|
||||||
queryset=IKEProposal.objects.all(),
|
queryset=IKEProposal.objects.all(),
|
||||||
label=_('Proposals')
|
label=_('Proposals'),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@ -349,7 +351,8 @@ class IPSecProposalForm(NetBoxModelForm):
|
|||||||
class IPSecPolicyForm(NetBoxModelForm):
|
class IPSecPolicyForm(NetBoxModelForm):
|
||||||
proposals = DynamicModelMultipleChoiceField(
|
proposals = DynamicModelMultipleChoiceField(
|
||||||
queryset=IPSecProposal.objects.all(),
|
queryset=IPSecProposal.objects.all(),
|
||||||
label=_('Proposals')
|
label=_('Proposals'),
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
|
@ -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
|
||||||
|
@ -40,7 +40,8 @@ class WirelessLANForm(ScopedForm, TenancyForm, NetBoxModelForm):
|
|||||||
group = DynamicModelChoiceField(
|
group = DynamicModelChoiceField(
|
||||||
label=_('Group'),
|
label=_('Group'),
|
||||||
queryset=WirelessLANGroup.objects.all(),
|
queryset=WirelessLANGroup.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
quick_add=True
|
||||||
)
|
)
|
||||||
vlan = DynamicModelChoiceField(
|
vlan = DynamicModelChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
|
@ -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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user