diff --git a/README.md b/README.md index 6e2b34fb8..b7dfc9e43 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations. ## Project Stats
- Timeline graph - Issues graph - Pull requests graph - Top contributors + Timeline graph + Issues graph + Pull requests graph + Top contributors
Stats via Repography
diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 49d2e6347..7b82ed07c 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,5 +1,27 @@ # NetBox v3.5 +## v3.5.5 (FUTURE) + +### Enhancements + +* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type + +### Bug Fixes + +* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes +* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients +* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment +* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields +* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs +* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled +* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer +* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets +* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit +* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types +* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links + +--- + ## v3.5.4 (2023-06-20) ### Enhancements diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 9550df3ea..b7e537c23 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -1,5 +1,6 @@ import re import typing +from collections import OrderedDict from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.openapi import AutoSchema @@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension): target_class = 'netbox.api.fields.ChoiceField' def map_serializer_field(self, auto_schema, direction): + build_cf = build_choice_field(self.target) + if direction == 'request': - return build_choice_field(self.target) + return build_cf elif direction == "response": + value = build_cf + label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))} + return build_object_type( properties={ - "value": build_basic_type(OpenApiTypes.STR), - "label": build_basic_type(OpenApiTypes.STR), + "value": value, + "label": label } ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3f6d55da7..2f854d3e4 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -707,7 +707,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') device = NestedDeviceSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True) @@ -880,12 +880,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) - rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True) - rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True) - poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True) - poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -907,9 +907,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect mac_address = serializers.CharField( required=False, default=None, + allow_blank=True, allow_null=True ) - wwn = serializers.CharField(required=False, default=None) + wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True) class Meta: model = Interface diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index cc388b750..f2f401718 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -810,6 +810,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' + TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd' TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' @@ -959,6 +960,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), + (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index de7575acb..8c8c4f79a 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm): model = DeviceType fields = [ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', + 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags', ] @@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags'] class DeviceRoleImportForm(NetBoxModelImportForm): diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index b3a4d090c..31e7cb2d3 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -10,7 +10,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import Q -from django.http import QueryDict from django.template.loader import render_to_string from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ @@ -19,7 +18,7 @@ from extras.utils import FeatureQuery from utilities.forms import BootstrapMixin from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, get_viewname +from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname from .utils import register_widget __all__ = ( @@ -170,8 +169,7 @@ class ObjectCountsWidget(DashboardWidget): qs = model.objects.restrict(request.user, 'view') # Apply any specified filters if filters := self.config.get('filters'): - params = QueryDict(mutable=True) - params.update(filters) + params = dict_to_querydict(filters) filterset = getattr(resolve(url).func.view_class, 'filterset', None) qs = filterset(params, qs).qs url = f'{url}?{params.urlencode()}' diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index b7034645f..fcf5c26a2 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models -from django.http import HttpResponse, QueryDict +from django.http import HttpResponse from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -26,7 +26,7 @@ from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) from utilities.querysets import RestrictedQuerySet -from utilities.utils import clean_html, render_jinja2 +from utilities.utils import clean_html, dict_to_querydict, render_jinja2 __all__ = ( 'ConfigRevision', @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote(link, safe='/:?&=%+[]@#') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#,') # Verify link scheme is allowed result = urllib.parse.urlparse(link) @@ -462,8 +462,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): @property def url_params(self): - qd = QueryDict(mutable=True) - qd.update(self.parameters) + qd = dict_to_querydict(self.parameters) return qd.urlencode() diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a128b6acc..d011472d9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext as _ +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except (AddrFormatError, ValueError): return queryset.none() + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset return queryset.filter(address__net_mask_length=value) + @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: return queryset.none @@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet): Q(name__icontains=value) ) + @extend_schema_field(OpenApiTypes.STR) def filter_related_ip(self, queryset, name, value): """ Filter by VRF & prefix of assigned IP addresses. @@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): pass return queryset.filter(qs_filter) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_virtualmachine(self, queryset, name, value): return queryset.get_for_virtualmachine(value) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index b0b08e4e0..6fa0f95ea 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -345,7 +345,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( "Cannot reassign IP address while it is designated as the primary IP for the parent object" ) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 814ca1ed6..f0bd5fd27 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication): user = token.user # When LDAP authentication is active try to load user data from LDAP directory - if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend': + if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND: from netbox.authentication import LDAPBackend ldap_backend = LDAPBackend() diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 76b3e42a8..ba6967f1f 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -203,7 +203,7 @@ class MaintenanceModeMiddleware: """ Prevent any write-related database operations if an exception is raised. """ - if isinstance(exception, InternalError): + if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError): error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \ 'operations. Please try again later.' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 22193dbac..31363144f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.4' +VERSION = '3.5.5-dev' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index e66e79a7a..35caa31b3 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, m2m_field in m2m_fields.items(): if name in form.nullable_fields and name in nullified_fields: getattr(obj, name).clear() - else: + elif form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Add/remove tags diff --git a/netbox/templates/tenancy/object_contacts.html b/netbox/templates/tenancy/object_contacts.html index bbe2d9bc1..e13fedc43 100644 --- a/netbox/templates/tenancy/object_contacts.html +++ b/netbox/templates/tenancy/object_contacts.html @@ -2,10 +2,12 @@ {% load helpers %} {% block extra_controls %} - {% if perms.tenancy.add_contactassignment %} - + {% if perms.tenancy.add_contactassignment %} + {% with viewname=object|viewname:"contacts" %} + Add a contact - + + {% endwith %} {% endif %} {% endblock %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index dc86586e7..f9fe5f4e3 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,6 +1,8 @@ from django import template from django.http import QueryDict +from utilities.utils import dict_to_querydict + __all__ = ( 'badge', 'checkmark', @@ -87,8 +89,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs): viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`) return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used. """ - url_params = QueryDict(mutable=True) - url_params.update(kwargs) + url_params = dict_to_querydict(kwargs) url_params['return_url'] = return_url or context['request'].path return { 'viewname': viewname, diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 4b4a2631a..114397dae 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -11,8 +11,9 @@ from django.core import serializers from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict -from django.utils.html import escape from django.utils import timezone +from django.utils.datastructures import MultiValueDict +from django.utils.html import escape from django.utils.timezone import localtime from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel @@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''): return params +def dict_to_querydict(d, mutable=True): + """ + Create a QueryDict instance from a regular Python dictionary. + """ + qd = QueryDict(mutable=True) + for k, v in d.items(): + item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v} + qd.update(item) + if not mutable: + qd._mutable = False + return qd + + def normalize_querydict(querydict): """ Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,