mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 01:58:43 -06:00
Merge branch 'main' into feature
This commit is contained in:
@@ -1110,6 +1110,13 @@ class DeviceFilterSet(
|
||||
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)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -1739,6 +1746,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceModeChoices,
|
||||
label=_('802.1Q Mode')
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, VRF
|
||||
from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||
from netbox.choices import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
@@ -1356,6 +1356,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1427,6 +1428,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
mode = forms.MultipleChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
label=_('802.1Q mode')
|
||||
)
|
||||
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Translation Policy')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
|
||||
@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
# Validate that all replication fields generate an equal number of values (or a single value)
|
||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||
return
|
||||
|
||||
pattern_count = len(patterns)
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
if self.cleaned_data[field_name]:
|
||||
if value_count == 1:
|
||||
# If the field resolves to a single value (because no pattern was used), multiply it by the number
|
||||
# of expected values. This allows us to reuse the same label when creating multiple components.
|
||||
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
|
||||
elif value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
#
|
||||
@@ -404,6 +408,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_chassis_id': 'null',
|
||||
'site_id': '$site',
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
|
||||
@@ -225,8 +225,7 @@ class CableTraceSVG:
|
||||
"""
|
||||
nodes_height = 0
|
||||
nodes = []
|
||||
# Sort them by name to make renders more readable
|
||||
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
|
||||
for i, term in enumerate(terminations):
|
||||
node = Node(
|
||||
position=(offset_x + i * width, self.cursor),
|
||||
width=width,
|
||||
|
||||
@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
|
||||
|
||||
INTERFACE_FHRPGROUPS = """
|
||||
{% for assignment in value.all %}
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -2801,6 +2801,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rack(self):
|
||||
racks = Rack.objects.all()[:2]
|
||||
@@ -4416,7 +4418,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_mode(self):
|
||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
||||
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
@@ -236,7 +236,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
regions,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
@@ -246,8 +246,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
).distinct(),
|
||||
'region_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Region),
|
||||
scope_id__in=regions
|
||||
).distinct(),
|
||||
'region'
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
|
||||
'region_id'
|
||||
),
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
@@ -330,10 +341,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
groups,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(
|
||||
ASN.objects.restrict(request.user, 'view').filter(
|
||||
sites__group__in=groups
|
||||
).distinct(),
|
||||
'site_group_id'),
|
||||
(
|
||||
VirtualMachine.objects.restrict(request.user, 'view').filter(
|
||||
site__group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(SiteGroup),
|
||||
scope_id__in=groups
|
||||
).distinct(),
|
||||
'site_group'
|
||||
),
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
terminations___site_group=instance
|
||||
@@ -342,6 +372,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
@@ -444,6 +478,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -523,7 +558,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
locations,
|
||||
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
|
||||
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
@@ -533,6 +568,10 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
|
||||
'location_id'
|
||||
),
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
@@ -793,7 +832,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
])
|
||||
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(CableTermination,),
|
||||
extra=(
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Rack),
|
||||
scope_id=instance.pk
|
||||
), 'rack'),
|
||||
),
|
||||
),
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
|
||||
Reference in New Issue
Block a user