Merge branch 'netbox-community:main' into 17686-config_option_for_disk_divider

This commit is contained in:
Mika Busch 2025-03-07 10:54:35 +01:00 committed by GitHub
commit 9f5c1c1367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 53563 additions and 44823 deletions

View File

@ -15,7 +15,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.2.4 placeholder: v4.2.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -27,7 +27,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.2.4 placeholder: v4.2.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -308,6 +308,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional) * `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below) * `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
* `null_option` - A label representing a "null" or empty choice (optional) * `null_option` - A label representing a "null" or empty choice (optional)
* `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status: To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:

View File

@ -1,5 +1,36 @@
# NetBox v4.2 # NetBox v4.2
## v4.2.5 (2025-03-06)
### Enhancements
* [#17357](https://github.com/netbox-community/netbox/issues/17357) - Use VirtualChassis name as fallback for unnamed devices
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces
* [#18772](https://github.com/netbox-community/netbox/issues/18772) - Add "type" filter for virtual circuits
* [#18774](https://github.com/netbox-community/netbox/issues/18774) - Add tooltip preview of tag descriptions when hovering over tags
### Bug Fixes
* [#15016](https://github.com/netbox-community/netbox/issues/15016) - Prevent AssertionError when adding multiple devices "mid-span" in a cable trace
* [#15924](https://github.com/netbox-community/netbox/issues/15924) - Prevent setting tagged VLANs on interfaces with mode: tagged-all
* [#17488](https://github.com/netbox-community/netbox/issues/17488) - Ensure VLANGroup.vid_ranges shows up in API results
* [#17709](https://github.com/netbox-community/netbox/issues/17709) - Allow primary key for nested models in OpenAPI request schemas
* [#17796](https://github.com/netbox-community/netbox/issues/17796) - Fix IndexError on "Create & Add Another" operation on custom field choices
* [#18605](https://github.com/netbox-community/netbox/issues/18605) - Limit VLAN selection dropdown to choices appropriate to site
* [#18722](https://github.com/netbox-community/netbox/issues/18722) - Improve UI feedback on failed script execution
* [#18729](https://github.com/netbox-community/netbox/issues/18729) - Fix unpredictable ordering on querysets with annotations/groupings
* [#18753](https://github.com/netbox-community/netbox/issues/18753) - Prevent webhooks from being triggered on a script dry-run
* [#18758](https://github.com/netbox-community/netbox/issues/18758) - Fix FieldError when sorting by account count field in providers list
* [#18768](https://github.com/netbox-community/netbox/issues/18768) - Fix removing a secondary MAC address from an interface
---
## v4.2.4 (2025-02-21) ## v4.2.4 (2025-02-21)
### Enhancements ### Enhancements

View File

@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
) )
class ProviderAccountFilterSet(NetBoxModelFilterSet): class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
label=_('Provider (ID)'), label=_('Provider (ID)'),

View File

@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class ProviderAccountFilterForm(NetBoxModelFilterSetForm): class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ProviderAccount model = ProviderAccount
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'account', name=_('Attributes')), FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
@ -327,7 +328,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type', 'status', name=_('Attributes')), FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
) )
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id') selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')

View File

@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
verbose_name=_('Accounts') verbose_name=_('Accounts')
) )
account_count = columns.LinkedCountColumn( account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list', viewname='circuits:provideraccount_list',
url_params={'provider_id': 'pk'}, url_params={'provider_id': 'pk'},
verbose_name=_('Account Count') verbose_name=_('Account Count')

View File

@ -23,6 +23,7 @@ class ProviderListView(generic.ObjectListView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
count_circuits=count_related(Circuit, 'provider'), count_circuits=count_related(Circuit, 'provider'),
asn_count=count_related(ASN, 'providers'), asn_count=count_related(ASN, 'providers'),
account_count=count_related(ProviderAccount, 'provider'),
) )
filterset = filtersets.ProviderFilterSet filterset = filtersets.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm

View File

@ -2,12 +2,13 @@ import re
import typing import typing
from collections import OrderedDict from collections import OrderedDict
from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
from drf_spectacular.openapi import AutoSchema from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import ( from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
) )
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import Direction
from netbox.api.fields import ChoiceField from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
return component.ref if component else None return component.ref if component else None
else: else:
return build_basic_type(OpenApiTypes.INT) return build_basic_type(OpenApiTypes.INT)
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
target_class = 'netbox.api.fields.IntegerRangeSerializer'
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
return {
'type': 'array',
'items': {
'type': 'array',
'items': {
'type': 'integer',
},
'minItems': 2,
'maxItems': 2,
},
}
# Nested models can be passed by ID in requests
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.serializers.BaseModelSerializer'
match_subclasses = True
def map_serializer_field(self, auto_schema, direction):
schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
if schema is None:
return schema
if direction == 'request' and self.target.nested:
return {
'oneOf': [
build_basic_type(OpenApiTypes.INT),
schema,
]
}
return schema

View File

@ -165,7 +165,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Job, 'list', path='', detail=False) @register_model_view(Job, 'list', path='', detail=False)
class JobListView(generic.ObjectListView): class JobListView(generic.ObjectListView):
queryset = Job.objects.all() queryset = Job.objects.defer('data')
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm filterset_form = forms.JobFilterForm
table = tables.JobTable table = tables.JobTable
@ -182,12 +182,12 @@ class JobView(generic.ObjectView):
@register_model_view(Job, 'delete') @register_model_view(Job, 'delete')
class JobDeleteView(generic.ObjectDeleteView): class JobDeleteView(generic.ObjectDeleteView):
queryset = Job.objects.all() queryset = Job.objects.defer('data')
@register_model_view(Job, 'bulk_delete', path='delete', detail=False) @register_model_view(Job, 'bulk_delete', path='delete', detail=False)
class JobBulkDeleteView(generic.BulkDeleteView): class JobBulkDeleteView(generic.BulkDeleteView):
queryset = Job.objects.all() queryset = Job.objects.defer('data')
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
table = tables.JobTable table = tables.JobTable

View File

@ -1,3 +1,4 @@
from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType 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
@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
def validate(self, data): def validate(self, data):
# Validate many-to-many VLAN assignments
if not self.nested: if not self.nested:
# Validate 802.1q mode and vlan(s)
mode = None
tagged_vlans = []
# Gather Information
if self.instance:
mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \
self.instance.untagged_vlan
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \
self.instance.qinq_svlan
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \
self.instance.tagged_vlans.all()
else:
mode = data.get('mode', None)
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None
errors = {}
# Non Q-in-Q mode with service vlan set
if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan:
errors.update({
'qinq_svlan': _("Interface mode does not support q-in-q service vlan")
})
# Routed mode
if not mode:
# Untagged vlan
if untagged_vlan:
errors.update({
'untagged_vlan': _("Interface mode does not support untagged vlan")
})
# Tagged vlan
if tagged_vlans:
errors.update({
'tagged_vlans': _("Interface mode does not support tagged vlans")
})
# Non-tagged mode
elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans:
errors.update({
'tagged_vlans': _("Interface mode does not support tagged vlans")
})
if errors:
raise serializers.ValidationError(errors)
# Validate many-to-many VLAN assignments
device = self.instance.device if self.instance else data.get('device') device = self.instance.device if self.instance else data.get('device')
for vlan in data.get('tagged_vlans', []): for vlan in data.get('tagged_vlans', []):
if vlan.site not in [device.site, None]: if vlan.site not in [device.site, None]:

View File

@ -0,0 +1,2 @@
class UnsupportedCablePath(Exception):
pass

View File

@ -1193,6 +1193,7 @@ class DeviceFilterSet(
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(virtual_chassis__name__icontains=value) |
Q(serial__icontains=value.strip()) | Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) |

View File

@ -1411,7 +1411,7 @@ class InterfaceBulkEditForm(
form_from_model(Interface, [ form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'wireless_lans' 'wireless_lans', 'vlan_translation_policy'
]) ])
): ):
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
@ -1564,7 +1564,9 @@ class InterfaceBulkEditForm(
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')), FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
),
FieldSet( FieldSet(
TabbedGroups( TabbedGroups(
FieldSet('tagged_vlans', name=_('Assignment')), FieldSet('tagged_vlans', name=_('Assignment')),
@ -1579,7 +1581,7 @@ class InterfaceBulkEditForm(
nullable_fields = ( nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description', 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans' 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form):
super().clean() super().clean()
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
tagged_vlans = self.cleaned_data.get('tagged_vlans') if 'tagged_vlans' in self.fields.keys():
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
# Untagged interfaces cannot be assigned tagged VLANs self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans')
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: else:
raise forms.ValidationError({ tagged_vlans = []
'mode': _("An access interface cannot have tagged VLANs assigned.")
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
# Validate tagged VLANs; must be a global VLAN or in the same site # Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
valid_sites = [None, self.cleaned_data[parent_field].site] valid_sites = [None, self.cleaned_data[parent_field].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]

View File

@ -15,6 +15,7 @@ from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node from dcim.utils import decompile_path_node, object_to_path_node
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from wireless.models import WirelessLink from wireless.models import WirelessLink
@ -26,6 +27,7 @@ __all__ = (
'CableTermination', 'CableTermination',
) )
from ..exceptions import UnsupportedCablePath
trace_paths = Signal() trace_paths = Signal()
@ -236,8 +238,10 @@ class Cable(PrimaryModel):
for termination in self.b_terminations: for termination in self.b_terminations:
if not termination.pk or termination not in b_terminations: if not termination.pk or termination not in b_terminations:
CableTermination(cable=self, cable_end='B', termination=termination).save() CableTermination(cable=self, cable_end='B', termination=termination).save()
try:
trace_paths.send(Cable, instance=self, created=_created) trace_paths.send(Cable, instance=self, created=_created)
except UnsupportedCablePath as e:
raise AbortRequest(e)
def get_status_color(self): def get_status_color(self):
return LinkStatusChoices.colors.get(self.status) return LinkStatusChoices.colors.get(self.status)
@ -531,8 +535,8 @@ class CablePath(models.Model):
return None return None
# Ensure all originating terminations are attached to the same link # Ensure all originating terminations are attached to the same link
if len(terminations) > 1: if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
assert all(t.link == terminations[0].link for t in terminations[1:]) raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
path = [] path = []
position_stack = [] position_stack = []
@ -543,12 +547,13 @@ class CablePath(models.Model):
while terminations: while terminations:
# Terminations must all be of the same type # Terminations must all be of the same type
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
# All mid-span terminations must all be attached to the same device # All mid-span terminations must all be attached to the same device
if not isinstance(terminations[0], PathEndpoint): if (not isinstance(terminations[0], PathEndpoint) and not
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:]) raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
# Check for a split path (e.g. rear port fanning out to multiple front ports with # Check for a split path (e.g. rear port fanning out to multiple front ports with
# different cables attached) # different cables attached)
@ -571,8 +576,10 @@ class CablePath(models.Model):
return None return None
# Otherwise, halt the trace if no link exists # Otherwise, halt the trace if no link exists
break break
assert all(type(link) in (Cable, WirelessLink) for link in links) if not all(type(link) in (Cable, WirelessLink) for link in links):
assert all(isinstance(link, type(links[0])) for link in links) raise UnsupportedCablePath(_("All links must be cable or wireless"))
if not all(isinstance(link, type(links[0])) for link in links):
raise UnsupportedCablePath(_("All links must match first link type"))
# Step 3: Record asymmetric paths as split # Step 3: Record asymmetric paths as split
not_connected_terminations = [termination.link for termination in terminations if termination.link is None] not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
@ -653,14 +660,18 @@ class CablePath(models.Model):
positions = position_stack.pop() positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations # Ensure we have a number of positions equal to the amount of remote terminations
assert len(remote_terminations) == len(positions) if len(remote_terminations) != len(positions):
raise UnsupportedCablePath(
_("All positions counts within the path on opposite ends of links must match")
)
# Get our front ports # Get our front ports
q_filter = Q() q_filter = Q()
for rt in remote_terminations: for rt in remote_terminations:
position = positions.pop() position = positions.pop()
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position) q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
assert q_filter is not Q() if q_filter is Q():
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
front_ports = FrontPort.objects.filter(q_filter) front_ports = FrontPort.objects.filter(q_filter)
# Obtain the individual front ports based on the termination and position # Obtain the individual front ports based on the termination and position
elif position_stack: elif position_stack:

View File

@ -934,6 +934,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")}) raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
# VLAN validation # VLAN validation
if not self.mode and self.untagged_vlan:
raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
# Validate untagged VLAN # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:

View File

@ -802,14 +802,10 @@ class Device(
verbose_name_plural = _('devices') verbose_name_plural = _('devices')
def __str__(self): def __str__(self):
if self.name and self.asset_tag: if self.label and self.asset_tag:
return f'{self.name} ({self.asset_tag})' return f'{self.label} ({self.asset_tag})'
elif self.name: elif self.label:
return self.name return self.label
elif self.virtual_chassis and self.asset_tag:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
elif self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
elif self.device_type and self.asset_tag: elif self.device_type and self.asset_tag:
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
elif self.device_type: elif self.device_type:
@ -1073,14 +1069,22 @@ class Device(
device.location = self.location device.location = self.location
device.save() device.save()
@property
def label(self):
"""
Return the device name if set; otherwise return a generated name if available.
"""
if self.name:
return self.name
if self.virtual_chassis:
return f'{self.virtual_chassis.name}:{self.vc_position}'
@property @property
def identifier(self): def identifier(self):
""" """
Return the device name if set; otherwise return the Device's primary key as {pk} Return the device name if set; otherwise return the Device's primary key as {pk}
""" """
if self.name is not None: return self.label or '{{{}}}'.format(self.pk)
return self.name
return '{{{}}}'.format(self.pk)
@property @property
def primary_ip(self): def primary_ip(self):
@ -1546,7 +1550,10 @@ class MACAddress(PrimaryModel):
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
if original_assigned_object.primary_mac_address: if (
original_assigned_object.primary_mac_address
and original_assigned_object.primary_mac_address.pk == self.pk
):
if not assigned_object: if not assigned_object:
raise ValidationError( raise ValidationError(
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object") _("Cannot unassign MAC Address while it is designated as the primary MAC for an object")

View File

@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
('asset_tag', 50), ('asset_tag', 50),
('serial', 60), ('serial', 60),
('name', 100), ('name', 100),
('virtual_chassis', 200),
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )

View File

@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
def get_device_name(device): def get_device_name(device):
if device.virtual_chassis: if device.label:
name = f'{device.virtual_chassis.name}:{device.vc_position}' name = device.label
elif device.name:
name = device.name
else: else:
name = str(device.device_type) name = str(device.device_type)
if device.devicebay_count: if device.devicebay_count:

View File

@ -143,6 +143,7 @@ class PlatformTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn( name = tables.TemplateColumn(
verbose_name=_('Name'), verbose_name=_('Name'),
accessor=Accessor('label'),
template_code=DEVICE_LINK, template_code=DEVICE_LINK,
linkify=True linkify=True
) )
@ -671,7 +672,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
) )
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@ -1,3 +1,5 @@
import json
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -1748,6 +1750,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
}, },
] ]
def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
device = Device.objects.first()
data = {
'device': device.pk,
'name': 'Interface 1',
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
}
data.update({'mode': mode})
data.update(invalid_data)
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
content = json.loads(response.content)
for key in invalid_data.keys():
self.assertIn(key, content)
self.assertIsNone(content.get('data'))
def test_bulk_delete_child_interfaces(self): def test_bulk_delete_child_interfaces(self):
interface1 = Interface.objects.get(name='Interface 1') interface1 = Interface.objects.get(name='Interface 1')
device = interface1.device device = interface1.device
@ -1775,6 +1794,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
self.client.delete(self._get_list_url(), data, format='json', **self.header) self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
def test_create_child_interfaces_mode_invalid_data(self):
"""
POST data to test interface mode check and invalid tagged/untagged VLANS.
"""
self.add_permissions('dcim.add_interface')
vlans = VLAN.objects.all()[0:3]
# Routed mode, untagged, tagged and qinq service vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
'qinq_svlan': vlans[2].pk
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Routed mode, untagged and tagged vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Routed mode, untagged vlan
invalid_data = {
'untagged_vlan': vlans[0].pk,
}
self._perform_interface_test_with_invalid_data(None, invalid_data)
invalid_data = {
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
}
# Routed mode, qinq service vlan
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Access mode, tagged vlans
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
# All tagged mode, tagged vlans
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
invalid_data = {
'qinq_svlan': vlans[0].pk,
}
# Routed mode, qinq service vlan
self._perform_interface_test_with_invalid_data(None, invalid_data)
# Access mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
# Tagged mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data)
# Tagged-all mode, qinq service vlan
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
class FrontPortTest(APIViewTestCases.APIViewTestCase): class FrontPortTest(APIViewTestCases.APIViewTestCase):
model = FrontPort model = FrontPort

View File

@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from dcim.utils import object_to_path_node from dcim.utils import object_to_path_node
from utilities.exceptions import AbortRequest
class CablePathTestCase(TestCase): class CablePathTestCase(TestCase):
@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
b_terminations=[frontport1, frontport3], b_terminations=[frontport1, frontport3],
label='C1' label='C1'
) )
with self.assertRaises(AssertionError): with self.assertRaises(AbortRequest):
cable1.save() cable1.save()
self.assertPathDoesNotExist( self.assertPathDoesNotExist(
@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
label='C3' label='C3'
) )
with self.assertRaises(AssertionError): with self.assertRaises(AbortRequest):
cable3.save() cable3.save()
self.assertPathDoesNotExist( self.assertPathDoesNotExist(

View File

@ -1,8 +1,9 @@
from django.test import TestCase from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices
from dcim.forms import * from dcim.forms import *
from dcim.models import * from dcim.models import *
from ipam.models import VLAN
from utilities.testing import create_test_device from utilities.testing import create_test_device
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -117,11 +118,23 @@ class DeviceTestCase(TestCase):
self.assertIn('position', form.errors) self.assertIn('position', form.errors)
class LabelTestCase(TestCase): class InterfaceTestCase(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.device = create_test_device('Device 1') cls.device = create_test_device('Device 1')
cls.vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
)
VLAN.objects.bulk_create(cls.vlans)
cls.interface = Interface.objects.create(
device=cls.device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
mode=InterfaceModeChoices.MODE_TAGGED,
)
def test_interface_label_count_valid(self): def test_interface_label_count_valid(self):
""" """
@ -151,3 +164,152 @@ class LabelTestCase(TestCase):
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn('label', form.errors) self.assertIn('label', form.errors)
def test_create_interface_mode_valid_data(self):
"""
Test that saving valid interface mode and tagged/untagged vlans works properly
"""
# Validate access mode
data = {
'device': self.device.pk,
'name': 'ethernet1/1',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'untagged_vlan': self.vlans[0].pk
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
# Validate tagged vlans
data = {
'device': self.device.pk,
'name': 'ethernet1/2',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
# Validate tagged vlans
data = {
'device': self.device.pk,
'name': 'ethernet1/3',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'untagged_vlan': self.vlans[0].pk,
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
def test_create_interface_mode_access_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/4',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_access_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/5',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_ACCESS,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data, instance=self.interface)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_create_interface_mode_tagged_all_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/6',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_tagged_all_invalid_data(self):
"""
Test that saving invalid interface mode and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/7',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data)
self.assertTrue(form.is_valid())
self.assertIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_create_interface_mode_routed_invalid_data(self):
"""
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'ethernet1/6',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': None,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceCreateForm(data)
self.assertTrue(form.is_valid())
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
def test_edit_interface_mode_routed_invalid_data(self):
"""
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
"""
data = {
'device': self.device.pk,
'name': 'Ethernet 1/7',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mode': None,
'untagged_vlan': self.vlans[0].pk,
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
}
form = InterfaceForm(data)
self.assertTrue(form.is_valid())
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())

View File

@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import tag, TestCase
from circuits.models import * from circuits.models import *
from core.models import ObjectType from core.models import ObjectType
@ -12,6 +12,43 @@ from utilities.data import drange
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
class MACAddressTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
device = Device.objects.create(
name='Device 1', device_type=device_type, role=device_role, site=site,
)
cls.interface = Interface.objects.create(
device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
cls.interface.primary_mac_address = cls.mac_a
cls.interface.save()
@tag('regression')
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
self.mac_a.assigned_object = None
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
self.mac_a.clean()
@tag('regression')
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
self.mac_b.assigned_object = None
self.mac_b.clean()
class LocationTestCase(TestCase): class LocationTestCase(TestCase):
def test_change_location_site(self): def test_change_location_site(self):
@ -590,6 +627,32 @@ class DeviceTestCase(TestCase):
device2.full_clean() device2.full_clean()
device2.save() device2.save()
def test_device_label(self):
device1 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
)
self.assertEqual(device1.label, None)
device1.name = 'Test Device 1'
self.assertEqual(device1.label, 'Test Device 1')
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
device2 = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name=None,
virtual_chassis=virtual_chassis,
vc_position=2,
)
self.assertEqual(device2.label, 'VC 1:2')
device2.name = 'Test Device 2'
self.assertEqual(device2.label, 'Test Device 2')
def test_device_mismatched_site_cluster(self): def test_device_mismatched_site_cluster(self):
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type) Cluster.objects.create(name='Cluster 1', type=cluster_type)

View File

@ -162,6 +162,7 @@ class CustomFieldForm(forms.ModelForm):
class CustomFieldChoiceSetForm(forms.ModelForm): class CustomFieldChoiceSetForm(forms.ModelForm):
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
extra_choices = forms.CharField( extra_choices = forms.CharField(
widget=ChoicesWidget(), widget=ChoicesWidget(),
required=False, required=False,
@ -178,12 +179,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
# Escape colons in extra_choices # TODO: The check for str / list below is to handle difference in extra_choices field definition
# In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
# if standardize these, we can simplify this code
# Convert extra_choices Array Field from model to CharField for form
if 'extra_choices' in self.initial and self.initial['extra_choices']: if 'extra_choices' in self.initial and self.initial['extra_choices']:
choices = [] extra_choices = self.initial['extra_choices']
for choice in self.initial['extra_choices']: if isinstance(extra_choices, str):
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:')) extra_choices = [extra_choices]
choices.append(choice) choices = ""
for choice in extra_choices:
# Setup choices in Add Another use case
if isinstance(choice, str):
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
choices += choice_str + "\n"
# Setup choices in Edit use case
elif isinstance(choice, list):
choice_str = ":".join(choice)
choices += choice_str + "\n"
self.initial['extra_choices'] = choices self.initial['extra_choices'] = choices

View File

@ -100,7 +100,10 @@ class ScriptJob(JobRunner):
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc. # change logging, event rules, etc.
with ExitStack() as stack: if commit:
for request_processor in registry['request_processors']: with ExitStack() as stack:
stack.enter_context(request_processor(request)) for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
self.run_script(script, request, data, commit)
else:
self.run_script(script, request, data, commit) self.run_script(script, request, data, commit)

View File

@ -211,10 +211,12 @@ class ObjectVar(ScriptVariable):
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option> :param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
elements within the dropdown menu (optional) elements within the dropdown menu (optional)
:param null_option: The label to use as a "null" selection option (optional) :param null_option: The label to use as a "null" selection option (optional)
:param selector: Include an advanced object selection widget to assist the user in identifying the desired
object (optional)
""" """
form_field = DynamicModelChoiceField form_field = DynamicModelChoiceField
def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs): def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.field_attrs.update({ self.field_attrs.update({
@ -222,6 +224,7 @@ class ObjectVar(ScriptVariable):
'query_params': query_params, 'query_params': query_params,
'context': context, 'context': context,
'null_option': null_option, 'null_option': null_option,
'selector': selector,
}) })

View File

@ -75,8 +75,11 @@ urlpatterns = [
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'), path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'), path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'), path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'), path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
path('scripts/<str:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
path('scripts/<str:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))), path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
# Markdown # Markdown

View File

@ -1251,6 +1251,14 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
class BaseScriptView(generic.ObjectView): class BaseScriptView(generic.ObjectView):
queryset = Script.objects.all() queryset = Script.objects.all()
def get_object(self, **kwargs):
if pk := kwargs.get('pk', False):
return get_object_or_404(self.queryset, pk=pk)
elif (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
else:
raise Http404
def _get_script_class(self, script): def _get_script_class(self, script):
""" """
Return an instance of the Script's Python class Return an instance of the Script's Python class

View File

@ -12,7 +12,8 @@ from netaddr.core import AddrFormatError
from circuits.models import Provider from circuits.models import Provider
from dcim.models import Device, Interface, 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 ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
) )
@ -148,7 +149,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'is_private', 'description') fields = ('id', 'name', 'slug', 'is_private', 'description')
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='family' lookup_expr='family'
@ -276,7 +277,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'description', 'weight') fields = ('id', 'name', 'slug', 'description', 'weight')
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='prefix', field_name='prefix',
lookup_expr='family' lookup_expr='family'
@ -430,7 +431,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
).distinct() ).distinct()
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='start_address', field_name='start_address',
lookup_expr='family' lookup_expr='family'
@ -522,7 +523,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
return queryset.filter(q) return queryset.filter(q)
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
family = django_filters.NumberFilter( family = django_filters.NumberFilter(
field_name='address', field_name='address',
lookup_expr='family' lookup_expr='family'
@ -1136,7 +1137,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class ServiceFilterSet(NetBoxModelFilterSet): class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Device (ID)'), label=_('Device (ID)'),

View File

@ -6,7 +6,7 @@ from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
@ -94,12 +94,13 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = Aggregate model = Aggregate
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'rir_id', name=_('Attributes')), FieldSet('family', 'rir_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(
required=False, required=False,
@ -162,7 +163,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm, ):
model = Prefix model = Prefix
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
@ -174,6 +175,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
mask_length__lte = forms.IntegerField( mask_length__lte = forms.IntegerField(
widget=forms.HiddenInput() widget=forms.HiddenInput()
@ -262,12 +264,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange model = IPRange
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')), FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
family = forms.ChoiceField( family = forms.ChoiceField(
required=False, required=False,
@ -301,7 +304,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPAddress model = IPAddress
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
@ -312,6 +315,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')), FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role') selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
parent = forms.CharField( parent = forms.CharField(
@ -590,12 +594,13 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class ServiceFilterForm(ServiceTemplateFilterForm): class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
model = Service model = Service
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')), FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')), FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),

View File

@ -212,7 +212,7 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
required=False, required=False,
selector=True, selector=True,
query_params={ query_params={
'available_at_site': '$site', 'available_at_site': '$scope',
}, },
label=_('VLAN'), label=_('VLAN'),
) )
@ -240,6 +240,14 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
'tenant', 'description', 'comments', 'tags', 'tenant', 'description', 'comments', 'tags',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# #18605: only filter VLAN select list if scope field is a Site
if scope_field := self.fields.get('scope', None):
if scope_field.queryset.model is not Site:
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
class IPRangeForm(TenancyForm, NetBoxModelForm): class IPRangeForm(TenancyForm, NetBoxModelForm):
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(

View File

@ -0,0 +1,43 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Location, Region, Site, SiteGroup
from ipam.forms import PrefixForm
class PrefixFormTestCase(TestCase):
default_dynamic_params = '[{"fieldName":"scope","queryParam":"available_at_site"}]'
@classmethod
def setUpTestData(cls):
cls.site = Site.objects.create(name='Site 1', slug='site-1')
def test_vlan_field_sets_dynamic_params_by_default(self):
"""data-dynamic-params present when no scope_type selected"""
form = PrefixForm(data={})
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
def test_vlan_field_sets_dynamic_params_for_scope_site(self):
"""data-dynamic-params present when scope type is Site and when scope is specifc site"""
form = PrefixForm(data={
'scope_type': ContentType.objects.get_for_model(Site).id,
'scope': self.site,
})
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
"""data-dynamic-params not present when scope type is populated by is not Site"""
cases = [
Region(name='Region 1', slug='region-1'),
Location(site=self.site, name='Location 1', slug='location-1'),
SiteGroup(name='Site Group 1', slug='site-group-1'),
]
for case in cases:
form = PrefixForm(data={
'scope_type': ContentType.objects.get_for_model(case._meta.model).id,
'scope': case,
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs

View File

@ -121,6 +121,15 @@ class NetBoxModelViewSet(
obj.snapshot() obj.snapshot()
return obj return obj
def get_queryset(self):
"""
Reapply model-level ordering in case it has been lost through .annotate().
https://code.djangoproject.com/ticket/32811
"""
qs = super().get_queryset()
ordering = qs.model._meta.ordering
return qs.order_by(*ordering)
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True # If a list of objects has been provided, initialize the serializer with many=True
if isinstance(kwargs.get('data', {}), list): if isinstance(kwargs.get('data', {}), list):

View File

@ -125,6 +125,15 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Request handlers # Request handlers
# #
def get_queryset(self, request):
"""
Reapply model-level ordering in case it has been lost through .annotate().
https://code.djangoproject.com/ticket/32811
"""
qs = super().get_queryset(request)
ordering = qs.model._meta.ordering
return qs.order_by(*ordering)
def get(self, request): def get(self, request):
""" """
GET request handler. GET request handler.

View File

@ -166,7 +166,7 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
def get_jobs(self, instance): def get_jobs(self, instance):
object_type = ContentType.objects.get_for_model(instance) object_type = ContentType.objects.get_for_model(instance)
return Job.objects.filter( return Job.objects.defer('data').filter(
object_type=object_type, object_type=object_type,
object_id=instance.id object_id=instance.id
) )

View File

@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.83.4: sass@1.85.0:
version "1.83.4" version "1.85.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1" resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA== integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
dependencies: dependencies:
chokidar "^4.0.0" chokidar "^4.0.0"
immutable "^5.0.2" immutable "^5.0.2"
@ -2882,10 +2882,10 @@ toggle-selection@^1.0.6:
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
tom-select@2.4.2: tom-select@2.4.3:
version "2.4.2" version "2.4.3"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.2.tgz#9764faf6cba51f6571d03a79bb7c1cac1cac7a5a" resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
integrity sha512-2RWjkL3gMDz9E+u8w+tQy9JWsYq8gaSytEVeugKYDeMus6ZtxT1HttLPnXsfHCnBPlsNubVyj5gtUeN+S+bcpA== integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
dependencies: dependencies:
"@orchidjs/sifter" "^1.1.0" "@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.1.2" "@orchidjs/unicode-variants" "^1.1.2"

View File

@ -1,3 +1,3 @@
version: "4.2.4" version: "4.2.5"
edition: "Community" edition: "Community"
published: "2025-02-21" published: "2025-03-06"

View File

@ -54,3 +54,7 @@
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}
{% block modals %}
{% include 'inc/htmx_modal.html' with size='lg' %}
{% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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, reverse_lazy from django.urls import reverse
from utilities.forms import widgets from utilities.forms import widgets
from utilities.views import get_viewname from utilities.views import get_viewname
@ -171,10 +171,8 @@ class DynamicModelChoiceMixin:
# Include quick add? # Include quick add?
if self.quick_add: if self.quick_add:
app_label = self.model._meta.app_label
model_name = self.model._meta.model_name
widget.quick_add_context = { widget.quick_add_context = {
'url': reverse_lazy(f'{app_label}:{model_name}_add'), 'url': reverse(get_viewname(self.model, 'add')),
'params': {}, 'params': {},
} }
for k, v in self.quick_add_params.items(): for k, v in self.quick_add_params.items():

View File

@ -132,8 +132,9 @@ class TableConfigForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Initialize columns field based on table attributes # Initialize columns field based on table attributes
self.fields['available_columns'].choices = table.available_columns if table:
self.fields['columns'].choices = table.selected_columns self.fields['available_columns'].choices = table.available_columns
self.fields['columns'].choices = table.selected_columns
@property @property
def table_name(self): def table_name(self):

View File

@ -1,3 +1,3 @@
{% load helpers %} {% load helpers %}
{% if viewname %}<a href="{% url viewname %}?tag={{ tag.slug }}">{% endif %}<span class="badge" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>{% if viewname %}</a>{% endif %} {% if viewname %}<a href="{% url viewname %}?tag={{ tag.slug }}">{% endif %}<span {% if tag.description %}title="{{ tag.description }}"{% endif %} class="badge" style="color: {{ tag.color|fgcolor }}; background-color: #{{ tag.color }}">{{ tag }}</span>{% if viewname %}</a>{% endif %}

View File

@ -6,7 +6,7 @@ from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms.mixins import ScopedBulkEditForm from dcim.forms.mixins import ScopedBulkEditForm
from dcim.models import Device, DeviceRole, Platform, Site from dcim.models import Device, DeviceRole, Platform, Site
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import VLAN, VLANGroup, VRF from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VRF
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import BulkRenameForm, add_blank_choice from utilities.forms import BulkRenameForm, add_blank_choice
@ -242,15 +242,23 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
label=_('VRF') label=_('VRF')
) )
vlan_translation_policy = DynamicModelChoiceField(
queryset=VLANTranslationPolicy.objects.all(),
required=False,
label=_('VLAN Translation Policy')
)
model = VMInterface model = VMInterface
fieldsets = ( fieldsets = (
FieldSet('mtu', 'enabled', 'vrf', 'description'), FieldSet('mtu', 'enabled', 'vrf', 'description'),
FieldSet('parent', 'bridge', name=_('Related Interfaces')), FieldSet('parent', 'bridge', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy',
name=_('802.1Q Switching')
),
) )
nullable_fields = ( nullable_fields = (
'parent', 'bridge', 'mtu', 'vrf', 'description', 'parent', 'bridge', 'mtu', 'vrf', 'description', 'vlan_translation_policy',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -122,7 +122,7 @@ class VMInterfaceTable(BaseInterfaceTable):
fields = ( fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mtu', 'mode', 'description', 'tags', 'vrf', 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mtu', 'mode', 'description', 'tags', 'vrf',
'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'qinq_svlan', 'created', 'last_updated', 'qinq_svlan', 'created', 'last_updated', 'vlan_translation_policy',
) )
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from dcim.models import Device, Interface from dcim.models import Device, Interface
from ipam.models import IPAddress, RouteTarget, VLAN from ipam.models import IPAddress, RouteTarget, VLAN
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
@ -25,14 +25,14 @@ __all__ = (
) )
class TunnelGroupFilterSet(OrganizationalModelFilterSet): class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
class Meta: class Meta:
model = TunnelGroup model = TunnelGroup
fields = ('id', 'name', 'slug', 'description') fields = ('id', 'name', 'slug', 'description')
class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=TunnelStatusChoices choices=TunnelStatusChoices
) )
@ -293,7 +293,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet):
) )
class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
type = django_filters.MultipleChoiceFilter( type = django_filters.MultipleChoiceFilter(
choices=L2VPNTypeChoices, choices=L2VPNTypeChoices,
null_value=None null_value=None

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext as _
from dcim.models import Device, Region, Site from dcim.models import Device, Region, Site
from ipam.models import RouteTarget, VLAN from ipam.models import RouteTarget, VLAN
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
) )
@ -30,18 +30,23 @@ __all__ = (
) )
class TunnelGroupFilterForm(NetBoxModelFilterSetForm): class TunnelGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = TunnelGroup model = TunnelGroup
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
tag = TagFilterField(model) tag = TagFilterField(model)
class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class TunnelFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = Tunnel model = Tunnel
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')), FieldSet('status', 'encapsulation', 'tunnel_id', name=_('Tunnel')),
FieldSet('ipsec_profile_id', name=_('Security')), FieldSet('ipsec_profile_id', name=_('Security')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'), label=_('Status'),
@ -206,12 +211,13 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = L2VPN model = L2VPN
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'), label=_('Type'),

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, TagsMixin
from vpn.choices import * from vpn.choices import *
__all__ = ( __all__ = (
@ -16,7 +16,7 @@ __all__ = (
) )
class TunnelGroup(OrganizationalModel): class TunnelGroup(ContactsMixin, OrganizationalModel):
""" """
An administrative grouping of Tunnels. This can be used to correlate peer-to-peer tunnels which form a mesh, An administrative grouping of Tunnels. This can be used to correlate peer-to-peer tunnels which form a mesh,
for example. for example.
@ -27,7 +27,7 @@ class TunnelGroup(OrganizationalModel):
verbose_name_plural = _('tunnel groups') verbose_name_plural = _('tunnel groups')
class Tunnel(PrimaryModel): class Tunnel(ContactsMixin, PrimaryModel):
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
max_length=100, max_length=100,

View File

@ -68,6 +68,11 @@ class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.TunnelGroupTable table = tables.TunnelGroupTable
@register_model_view(TunnelGroup, 'contacts')
class TunnelGroupContactsView(ObjectContactsView):
queryset = TunnelGroup.objects.all()
# #
# Tunnels # Tunnels
# #
@ -132,6 +137,11 @@ class TunnelBulkDeleteView(generic.BulkDeleteView):
table = tables.TunnelTable table = tables.TunnelTable
@register_model_view(Tunnel, 'contacts')
class TunnelContactsView(ObjectContactsView):
queryset = Tunnel.objects.all()
# #
# Tunnel terminations # Tunnel terminations
# #

View File

@ -3,8 +3,10 @@ import logging
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from dcim.exceptions import UnsupportedCablePath
from dcim.models import CablePath, Interface from dcim.models import CablePath, Interface
from dcim.utils import create_cablepath from dcim.utils import create_cablepath
from utilities.exceptions import AbortRequest
from .models import WirelessLink from .models import WirelessLink
@ -34,7 +36,10 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs):
# Create/update cable paths # Create/update cable paths
if created: if created:
for interface in (instance.interface_a, instance.interface_b): for interface in (instance.interface_a, instance.interface_b):
create_cablepath([interface]) try:
create_cablepath([interface])
except UnsupportedCablePath as e:
raise AbortRequest(e)
@receiver(post_delete, sender=WirelessLink) @receiver(post_delete, sender=WirelessLink)

View File

@ -1,4 +1,4 @@
Django==5.1.6 Django==5.1.7
django-cors-headers==4.7.0 django-cors-headers==4.7.0
django-debug-toolbar==5.0.1 django-debug-toolbar==5.0.1
django-filter==25.1 django-filter==25.1
@ -15,23 +15,23 @@ django-tables2==2.7.5
django-timezone-field==7.1 django-timezone-field==7.1
djangorestframework==3.15.2 djangorestframework==3.15.2
drf-spectacular==0.28.0 drf-spectacular==0.28.0
drf-spectacular-sidecar==2025.2.1 drf-spectacular-sidecar==2025.3.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==23.0.0 gunicorn==23.0.0
Jinja2==3.1.5 Jinja2==3.1.6
Markdown==3.7 Markdown==3.7
mkdocs-material==9.6.5 mkdocs-material==9.6.7
mkdocstrings[python-legacy]==0.27.0 mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.20 nh3==0.2.21
Pillow==11.1.0 Pillow==11.1.0
psycopg[c,pool]==3.2.4 psycopg[c,pool]==3.2.5
PyYAML==6.0.2 PyYAML==6.0.2
requests==2.32.3 requests==2.32.3
rq==2.1.0 rq==2.1.0
social-auth-app-django==5.4.3 social-auth-app-django==5.4.3
social-auth-core==4.5.6 social-auth-core==4.5.6
strawberry-graphql==0.260.2 strawberry-graphql==0.262.0
strawberry-graphql-django==0.52.0 strawberry-graphql-django==0.52.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.8.0 tablib==3.8.0