Merge branch 'develop' into feature

This commit is contained in:
jeremystretch 2023-01-20 14:29:31 -05:00
commit dceffca849
27 changed files with 218 additions and 49 deletions

View File

@ -14,7 +14,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: v3.4.2 placeholder: v3.4.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,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: v3.4.2 placeholder: v3.4.3
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,12 +1,19 @@
# NetBox v3.4 # NetBox v3.4
## v3.4.3 (FUTURE) ## v3.4.4 (FUTURE)
---
## v3.4.3 (2023-01-20)
### Enhancements ### Enhancements
* [#9996](https://github.com/netbox-community/netbox/issues/9996) - Introduce `CA_CERT_PATH` parameter to define SSL CA path for Redis servers * [#9996](https://github.com/netbox-community/netbox/issues/9996) - Introduce `CA_CERT_PATH` parameter to define SSL CA path for Redis servers
* [#10486](https://github.com/netbox-community/netbox/issues/10486) - Add a cable edit button for connected components in component lists * [#10486](https://github.com/netbox-community/netbox/issues/10486) - Add a cable edit button for connected components in component lists
* [#11118](https://github.com/netbox-community/netbox/issues/11118) - Add L2VPN filters for VLANs and interfaces
* [#11150](https://github.com/netbox-community/netbox/issues/11150) - Add primary IPv4/v6 address filters for devices * [#11150](https://github.com/netbox-community/netbox/issues/11150) - Add primary IPv4/v6 address filters for devices
* [#11227](https://github.com/netbox-community/netbox/issues/11227) - Add 800GE interface types
* [#11228](https://github.com/netbox-community/netbox/issues/11228) - List both devices & VMs under device role view
* [#11245](https://github.com/netbox-community/netbox/issues/11245) - Enable export templates for journal entries * [#11245](https://github.com/netbox-community/netbox/issues/11245) - Enable export templates for journal entries
* [#11371](https://github.com/netbox-community/netbox/issues/11371) - Introduce additional 100M Ethernet interface types * [#11371](https://github.com/netbox-community/netbox/issues/11371) - Introduce additional 100M Ethernet interface types
@ -20,10 +27,16 @@
* [#11402](https://github.com/netbox-community/netbox/issues/11402) - Avoid LookupError exception when running scripts with commit disabled * [#11402](https://github.com/netbox-community/netbox/issues/11402) - Avoid LookupError exception when running scripts with commit disabled
* [#11403](https://github.com/netbox-community/netbox/issues/11403) - Fix exception when scheduling a job in the past * [#11403](https://github.com/netbox-community/netbox/issues/11403) - Fix exception when scheduling a job in the past
* [#11416](https://github.com/netbox-community/netbox/issues/11416) - Avoid AttributeError exception when deleting a cabled circuit termination * [#11416](https://github.com/netbox-community/netbox/issues/11416) - Avoid AttributeError exception when deleting a cabled circuit termination
* [#11433](https://github.com/netbox-community/netbox/issues/11433) - Avoid AttributeError exception when generating API schema for views with custom schema
* [#11438](https://github.com/netbox-community/netbox/issues/11438) - Fix deletion of scheduled job using non-default queues * [#11438](https://github.com/netbox-community/netbox/issues/11438) - Fix deletion of scheduled job using non-default queues
* [#11444](https://github.com/netbox-community/netbox/issues/11444) - Adding/removing a device from a device bay should record a pre-change snapshot on the device bay * [#11444](https://github.com/netbox-community/netbox/issues/11444) - Adding/removing a device from a device bay should record a pre-change snapshot on the device bay
* [#11467](https://github.com/netbox-community/netbox/issues/11467) - Correct count on interfaces tab when viewing a VC master device * [#11467](https://github.com/netbox-community/netbox/issues/11467) - Correct count on interfaces tab when viewing a VC master device
* [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields * [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields
* [#11488](https://github.com/netbox-community/netbox/issues/11488) - Add missing `description` fields to several REST API serializers
* [#11497](https://github.com/netbox-community/netbox/issues/11497) - Enforce `run_script` permission when executing scripts via REST API
* [#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex
* [#11522](https://github.com/netbox-community/netbox/issues/11522) - Correct tag links under contact & tenant list views
* [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
--- ---

View File

@ -77,6 +77,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'description',
] ]

View File

@ -672,6 +672,22 @@ class DeviceSerializer(NetBoxModelSerializer):
return data return data
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField()
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_config_context(self, obj):
return obj.get_config_context()
class VirtualDeviceContextSerializer(NetBoxModelSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
@ -687,7 +703,8 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
model = VirtualDeviceContext model = VirtualDeviceContext
fields = [ fields = [
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4', 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count', 'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count',
] ]
@ -706,22 +723,6 @@ class ModuleSerializer(NetBoxModelSerializer):
] ]
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField()
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_config_context(self, obj):
return obj.get_config_context()
class DeviceNAPALMSerializer(serializers.Serializer): class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.JSONField() method = serializers.JSONField()
@ -935,7 +936,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = RearPort model = RearPort
fields = ['id', 'url', 'display', 'name', 'label'] fields = ['id', 'url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@ -1059,7 +1060,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
] ]

View File

@ -813,6 +813,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
# Ethernet Backplane # Ethernet Backplane
TYPE_1GE_KX = '1000base-kx' TYPE_1GE_KX = '1000base-kx'
@ -954,6 +956,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
) )
), ),
( (

View File

@ -3,7 +3,7 @@ from django.contrib.auth.models import User
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import ASN, IPAddress, VRF from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import ( from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
) )
@ -1414,6 +1414,17 @@ class InterfaceFilterSet(
to_field_name='name', to_field_name='name',
label='Virtual Device Context', label='Virtual Device Context',
) )
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta: class Meta:
model = Interface model = Interface

View File

@ -6,7 +6,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import LocalConfigContextFilterForm from extras.forms import LocalConfigContextFilterForm
from ipam.models import ASN, VRF from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import ( from utilities.forms import (
@ -1112,7 +1112,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')), ('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
@ -1203,6 +1203,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False, required=False,
label='VRF' label='VRF'
) )
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -78,7 +78,7 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
'description', 'comments', 'tags', 'created', 'last_updated', 'description', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (

View File

@ -21,7 +21,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES from .constants import NONCONNECTABLE_IFACE_TYPES
@ -1639,6 +1641,42 @@ class DeviceRoleView(generic.ObjectView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'devices', path='devices')
class DeviceRoleDevicesView(generic.ObjectChildrenView):
queryset = DeviceRole.objects.all()
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
template_name = 'dcim/devicerole/devices.html'
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
permission='dcim.view_device',
weight=400
)
def get_children(self, request, parent):
return Device.objects.restrict(request.user, 'view').filter(device_role=parent)
@register_model_view(DeviceRole, 'virtual_machines', path='virtual-machines')
class DeviceRoleVirtualMachinesView(generic.ObjectChildrenView):
queryset = DeviceRole.objects.all()
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
template_name = 'dcim/devicerole/virtual_machines.html'
tab = ViewTab(
label=_('Virtual machines'),
badge=lambda obj: obj.virtual_machines.count(),
permission='virtualization.view_virtualmachine',
weight=500
)
def get_children(self, request, parent):
return VirtualMachine.objects.restrict(request.user, 'view').filter(role=parent)
@register_model_view(DeviceRole, 'edit') @register_model_view(DeviceRole, 'edit')
class DeviceRoleEditView(generic.ObjectEditView): class DeviceRoleEditView(generic.ObjectEditView):
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()

View File

@ -318,6 +318,10 @@ class ScriptViewSet(ViewSet):
""" """
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
""" """
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
script = self._get_script(pk)() script = self._get_script(pk)()
input_serializer = serializers.ScriptInputSerializer(data=request.data) input_serializer = serializers.ScriptInputSerializer(data=request.data)

View File

@ -590,6 +590,7 @@ class ScriptTest(APITestCase):
@skipIf(not rq_worker_running, "RQ worker not running") @skipIf(not rq_worker_running, "RQ worker not running")
def test_run_script(self): def test_run_script(self):
self.add_permissions('extras.run_script')
script_data = { script_data = {
'var1': 'FooBar', 'var1': 'FooBar',

View File

@ -852,6 +852,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
method='get_for_virtualmachine' method='get_for_virtualmachine'
) )
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta: class Meta:
model = VLAN model = VLAN

View File

@ -413,7 +413,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), ('Location', ('region_id', 'site_group_id', 'site_id')),
('Attributes', ('group_id', 'status', 'role_id', 'vid')), ('Attributes', ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@ -458,6 +458,11 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False, required=False,
label='VLAN ID' label='VLAN ID'
) )
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -1,3 +1,4 @@
import re
from collections import namedtuple from collections import namedtuple
from django.conf import settings from django.conf import settings
@ -160,7 +161,13 @@ class SearchView(View):
lookup=lookup lookup=lookup
) )
if form.cleaned_data['lookup'] != LookupTypes.EXACT: # If performing a regex search, pass the highlight value as a compiled pattern
if form.cleaned_data['lookup'] == LookupTypes.REGEX:
try:
highlight = re.compile(f"({form.cleaned_data['q']})", flags=re.IGNORECASE)
except re.error:
pass
elif form.cleaned_data['lookup'] != LookupTypes.EXACT:
highlight = form.cleaned_data['q'] highlight = form.cleaned_data['q']
table = SearchTable(results, highlight=highlight) table = SearchTable(results, highlight=highlight)

View File

@ -71,13 +71,6 @@
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-12"> <div class="col col-md-12">
<div class="card">
<h5 class="card-header">Devices</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
{% plugin_full_width_page object %} {% plugin_full_width_page object %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,21 @@
{% extends 'dcim/devicerole.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal='DeviceTable_config' %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:device_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -0,0 +1,21 @@
{% extends 'dcim/devicerole.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal='VirtualMachineTable_config' %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}"
hx-trigger="load"
></div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -62,7 +62,7 @@ class ContactTable(NetBoxTable):
verbose_name='Assignments' verbose_name='Assignments'
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='tenancy:tenant_list' url_name='tenancy:contact_list'
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):

View File

@ -40,7 +40,7 @@ class TenantTable(ContactsColumnMixin, NetBoxTable):
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='tenancy:contact_list' url_name='tenancy:tenant_list'
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):

View File

@ -27,7 +27,7 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
def get_request_serializer(self): def get_request_serializer(self):
serializer = super().get_request_serializer() serializer = super().get_request_serializer()
if serializer is not None and self.method in self.implicit_body_methods: if serializer is not None and not isinstance(serializer, openapi.Schema) and self.method in self.implicit_body_methods:
if writable_class := self.get_writable_class(serializer): if writable_class := self.get_writable_class(serializer):
if hasattr(serializer, 'child'): if hasattr(serializer, 'child'):
child_serializer = self.get_writable_class(serializer.child) child_serializer = self.get_writable_class(serializer.child)

View File

@ -1,6 +1,7 @@
import django_filters import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django_filters.constants import EMPTY_VALUES from django_filters.constants import EMPTY_VALUES
@ -67,6 +68,12 @@ class MACAddressFilter(django_filters.CharFilter):
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.CharField) field_class = multivalue_field_factory(forms.CharField)
def filter(self, qs, value):
try:
return super().filter(qs, value)
except ValidationError:
return qs.none()
class MultiValueWWNFilter(django_filters.MultipleChoiceFilter): class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.CharField) field_class = multivalue_field_factory(forms.CharField)

View File

@ -514,11 +514,21 @@ def clean_html(html, schemes):
def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'): def highlight_string(value, highlight, trim_pre=None, trim_post=None, trim_placeholder='...'):
""" """
Highlight a string within a string and optionally trim the pre/post portions of the original string. Highlight a string within a string and optionally trim the pre/post portions of the original string.
Args:
value: The body of text being searched against
highlight: The string of compiled regex pattern to highlight in `value`
trim_pre: Maximum length of pre-highlight text to include
trim_post: Maximum length of post-highlight text to include
trim_placeholder: String value to swap in for trimmed pre/post text
""" """
# Split value on highlight string # Split value on highlight string
try: try:
if type(highlight) is re.Pattern:
pre, match, post = highlight.split(value, maxsplit=1)
else:
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE) pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
except ValueError: except ValueError as e:
# Match not found # Match not found
return escape(value) return escape(value)

View File

@ -96,8 +96,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta): class Meta(VirtualMachineSerializer.Meta):
fields = [ fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
] ]
@swagger_serializer_method(serializer_or_field=serializers.JSONField) @swagger_serializer_method(serializer_or_field=serializers.JSONField)

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF from ipam.models import L2VPN, VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@ -295,6 +295,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
to_field_name='rd', to_field_name='rd',
label=_('VRF (RD)'), label=_('VRF (RD)'),
) )
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta: class Meta:
model = VMInterface model = VMInterface

View File

@ -3,7 +3,7 @@ from django.utils.translation import gettext as _
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF from ipam.models import L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import ( from utilities.forms import (
@ -177,7 +177,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Virtual Machine', ('cluster_id', 'virtual_machine_id')), ('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
('Attributes', ('enabled', 'mac_address', 'vrf_id')), ('Attributes', ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
) )
cluster_id = DynamicModelMultipleChoiceField( cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
@ -207,4 +207,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label='VRF' label='VRF'
) )
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -10,7 +10,7 @@ django-prometheus==2.2.0
django-redis==5.2.0 django-redis==5.2.0
django-rich==1.4.0 django-rich==1.4.0
django-rq==2.6.0 django-rq==2.6.0
django-tables2==2.5.0 django-tables2==2.5.1
django-taggit==3.1.0 django-taggit==3.1.0
django-timezone-field==5.0 django-timezone-field==5.0
djangorestframework==3.14.0 djangorestframework==3.14.0
@ -19,13 +19,13 @@ graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==8.5.11 mkdocs-material==9.0.6
mkdocstrings[python-legacy]==0.19.1 mkdocstrings[python-legacy]==0.20.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.4.0 Pillow==9.4.0
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.12.1 sentry-sdk==1.13.0
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0 social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3 svgwrite==1.4.3