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:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.2
placeholder: v3.4.3
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.2
placeholder: v3.4.3
validations:
required: true
- type: dropdown

View File

@ -1,12 +1,19 @@
# NetBox v3.4
## v3.4.3 (FUTURE)
## v3.4.4 (FUTURE)
---
## v3.4.3 (2023-01-20)
### Enhancements
* [#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
* [#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
* [#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
* [#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
* [#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
* [#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
* [#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
* [#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
fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'description',
]

View File

@ -672,6 +672,22 @@ class DeviceSerializer(NetBoxModelSerializer):
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):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device = NestedDeviceSerializer()
@ -687,7 +703,8 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
model = VirtualDeviceContext
fields = [
'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):
method = serializers.JSONField()
@ -935,7 +936,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta:
model = RearPort
fields = ['id', 'url', 'display', 'name', 'label']
fields = ['id', 'url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@ -1059,7 +1060,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
class Meta:
model = Cable
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_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
@ -954,6 +956,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (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 extras.filtersets import LocalConfigContextFilterSet
from ipam.models import ASN, IPAddress, VRF
from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
@ -1414,6 +1414,17 @@ class InterfaceFilterSet(
to_field_name='name',
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:
model = Interface

View File

@ -6,7 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from ipam.models import ASN, VRF
from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
@ -1112,7 +1112,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('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')),
('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',
@ -1203,6 +1203,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False,
label='VRF'
)
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model)

View File

@ -78,7 +78,7 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed
fields = (
'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',
)
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.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
@ -1639,6 +1641,42 @@ class DeviceRoleView(generic.ObjectView):
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')
class DeviceRoleEditView(generic.ObjectEditView):
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
"""
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)()
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")
def test_run_script(self):
self.add_permissions('extras.run_script')
script_data = {
'var1': 'FooBar',

View File

@ -852,6 +852,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
queryset=VirtualMachine.objects.all(),
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:
model = VLAN

View File

@ -413,7 +413,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('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')),
)
region_id = DynamicModelMultipleChoiceField(
@ -458,6 +458,11 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label='VLAN ID'
)
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model)

View File

@ -1,3 +1,4 @@
import re
from collections import namedtuple
from django.conf import settings
@ -160,7 +161,13 @@ class SearchView(View):
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']
table = SearchTable(results, highlight=highlight)

View File

@ -71,13 +71,6 @@
</div>
<div class="row mb-3">
<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 %}
</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'
)
tags = columns.TagColumn(
url_name='tenancy:tenant_list'
url_name='tenancy:contact_list'
)
class Meta(NetBoxTable.Meta):

View File

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

View File

@ -27,7 +27,7 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
def get_request_serializer(self):
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 hasattr(serializer, 'child'):
child_serializer = self.get_writable_class(serializer.child)

View File

@ -1,6 +1,7 @@
import django_filters
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django_filters.constants import EMPTY_VALUES
@ -67,6 +68,12 @@ class MACAddressFilter(django_filters.CharFilter):
class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
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):
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='...'):
"""
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
try:
pre, match, post = re.split(fr'({highlight})', value, maxsplit=1, flags=re.IGNORECASE)
except ValueError:
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)
except ValueError as e:
# Match not found
return escape(value)

View File

@ -96,8 +96,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@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 extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from ipam.models import L2VPN, VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@ -295,6 +295,17 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
to_field_name='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:
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 extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from ipam.models import L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
@ -177,7 +177,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Virtual Machine', ('cluster_id', 'virtual_machine_id')),
('Attributes', ('enabled', 'mac_address', 'vrf_id')),
('Attributes', ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
@ -207,4 +207,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
required=False,
label='VRF'
)
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model)

View File

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