mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 10:58:37 -06:00
Merge branch 'netbox-community:main' into 17686-config_option_for_disk_divider
This commit is contained in:
commit
9f5c1c1367
@ -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
|
||||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -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
|
||||||
|
@ -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:
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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)'),
|
||||||
|
@ -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')
|
||||||
|
@ -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')
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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]:
|
||||||
|
2
netbox/dcim/exceptions.py
Normal file
2
netbox/dcim/exceptions.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class UnsupportedCablePath(Exception):
|
||||||
|
pass
|
@ -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()) |
|
||||||
|
@ -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):
|
||||||
|
@ -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]
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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]:
|
||||||
|
@ -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")
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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:
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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())
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)'),
|
||||||
|
@ -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(),
|
||||||
|
@ -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(
|
||||||
|
43
netbox/ipam/tests/test_forms.py
Normal file
43
netbox/ipam/tests/test_forms.py
Normal 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
|
@ -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):
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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"
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
version: "4.2.4"
|
version: "4.2.5"
|
||||||
edition: "Community"
|
edition: "Community"
|
||||||
published: "2025-02-21"
|
published: "2025-03-06"
|
||||||
|
@ -54,3 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block modals %}
|
||||||
|
{% include 'inc/htmx_modal.html' with size='lg' %}
|
||||||
|
{% endblock %}
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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():
|
||||||
|
@ -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):
|
||||||
|
@ -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 %}
|
||||||
|
@ -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):
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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'),
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user