diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 99c060e8a..51c0a5a0d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.4.5 + placeholder: v3.4.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1338be9c1..7c6b4e151 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.4.5 + placeholder: v3.4.6 validations: required: true - type: dropdown diff --git a/README.md b/README.md index 053aa8461..e3c9611c0 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation. Available as open source software under the Apache 2.0 license, NetBox serves as the cornerstone for network automation in thousands of organizations. -* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power! +* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power! * **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support. -* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure. +* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure. * **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets. * **Organization:** Manage tenant and contact assignments natively. * **Powerful search:** Easily find anything you need using a single global search function. diff --git a/contrib/apache.conf b/contrib/apache.conf index 1804e380d..73fd45c26 100644 --- a/contrib/apache.conf +++ b/contrib/apache.conf @@ -1,3 +1,12 @@ + + # CHANGE THIS TO YOUR SERVER'S NAME + ServerName netbox.example.com + + RewriteEngine On + RewriteCond %{HTTPS} !=on + RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L] + + ProxyPreserveHost On diff --git a/docs/installation/5-http-server.md b/docs/installation/5-http-server.md index 907964554..b81c6d84a 100644 --- a/docs/installation/5-http-server.md +++ b/docs/installation/5-http-server.md @@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache: ```no-highlight -sudo a2enmod ssl proxy proxy_http headers +sudo a2enmod ssl proxy proxy_http headers rewrite sudo a2ensite netbox sudo systemctl restart apache2 ``` diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 11df3d47a..43f4dab5e 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,33 @@ # NetBox v3.4 +## v3.4.6 (2023-03-13) + +### Enhancements + +* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address +* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view +* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content +* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views +* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects +* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses +* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type +* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces +* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation + +### Bug Fixes + +* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address +* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation +* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs +* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles +* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified +* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type +* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark +* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables +* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths + +--- + ## v3.4.5 (2023-02-21) ### Enhancements diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index e1fe6338d..0449f8e99 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, ) @@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) @@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) @@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f1485b67f..c495c42ec 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_STACKWISE160 = 'cisco-stackwise-160' TYPE_STACKWISE320 = 'cisco-stackwise-320' TYPE_STACKWISE480 = 'cisco-stackwise-480' + TYPE_STACKWISE1T = 'cisco-stackwise-1t' TYPE_JUNIPER_VCP = 'juniper-vcp' TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' @@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_STACKWISE160, 'Cisco StackWise-160'), (TYPE_STACKWISE320, 'Cisco StackWise-320'), (TYPE_STACKWISE480, 'Cisco StackWise-480'), + (TYPE_STACKWISE1T, 'Cisco StackWise-1T'), (TYPE_JUNIPER_VCP, 'Juniper VCP'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), @@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet): TYPE_1_8023AF = 'type1-ieee802.3af' TYPE_2_8023AT = 'type2-ieee802.3at' + TYPE_2_8023AZ = 'type2-ieee802.3az' TYPE_3_8023BT = 'type3-ieee802.3bt' TYPE_4_8023BT = 'type4-ieee802.3bt' @@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet): ( (TYPE_1_8023AF, '802.3af (Type 1)'), (TYPE_2_8023AT, '802.3at (Type 2)'), + (TYPE_2_8023AZ, '802.3az (Type 2)'), (TYPE_3_8023BT, '802.3bt (Type 3)'), (TYPE_4_8023BT, '802.3bt (Type 4)'), ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index c10ef44c3..493ccbbea 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -981,7 +981,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter Q(serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) | - Q(comments__icontains=value) + Q(comments__icontains=value) | + Q(primary_ip4__address__startswith=value) | + Q(primary_ip6__address__startswith=value) ).distinct() def _has_primary_ip(self, queryset, name, value): @@ -1725,6 +1727,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): class CableTerminationFilterSet(BaseFilterSet): + termination_type = ContentTypeFilter() class Meta: model = CableTermination diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 38fa55738..bd466ca48 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, + DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget ) __all__ = ( @@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 3f016899e..da658d732 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -447,11 +447,14 @@ class DeviceImportForm(BaseDeviceImportForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) - # Limit rack queryset by assigned site and group + # Limit rack queryset by assigned site and location params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"location__{self.fields['location'].to_field_name}": data.get('location'), } + if 'location' in data: + params.update({ + f"location__{self.fields['location'].to_field_name}": data.get('location'), + }) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) # Limit device bay queryset by parent device diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index 133d6259f..f8e626fe8 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -10,3 +10,11 @@ class CabledObjectMixin: def resolve_link_peers(self, info): return self.link_peers + + +class PathEndpointMixin: + connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') + + def resolve_connected_endpoints(self, info): + # Handle empty values + return self.connected_endpoints or None diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 41f0092f9..3c6c0a885 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -7,7 +7,7 @@ from extras.graphql.mixins import ( from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType -from .mixins import CabledObjectMixin +from .mixins import CabledObjectMixin, PathEndpointMixin __all__ = ( 'CableType', @@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType): filterset_class = filtersets.CableTerminationFilterSet -class ConsolePortType(ComponentObjectType, CabledObjectMixin): +class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.ConsolePort @@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType): return self.type or None -class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin): +class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.ConsoleServerPort @@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.FrontPortTemplateFilterSet -class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin): +class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.Interface @@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(NetBoxObjectType, CabledObjectMixin): +class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.PowerFeed @@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin): filterset_class = filtersets.PowerFeedFilterSet -class PowerOutletType(ComponentObjectType, CabledObjectMixin): +class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.PowerOutlet @@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin): filterset_class = filtersets.PowerPanelFilterSet -class PowerPortType(ComponentObjectType, CabledObjectMixin): +class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): class Meta: model = models.PowerPort diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 730309156..686382a8c 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -588,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable): 'class': get_interface_row_class, 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, + 'data-type': lambda record: record.type, } diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 74b98ccf6..e2ccf1d34 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet): class JournalEntryFilterSet(NetBoxModelFilterSet): created = django_filters.DateTimeFromToRangeFilter() assigned_object_type = ContentTypeFilter() + assigned_object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContentType.objects.all() + ) created_by_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label=_('User (ID)'), @@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet): ) time = django_filters.DateTimeFromToRangeFilter() changed_object_type = ContentTypeFilter() + changed_object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContentType.objects.all() + ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label=_('User (ID)'), diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index af0f7cf43..0825c9ca7 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -2,6 +2,7 @@ from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * +from .misc import * from .mixins import * from .config import * from .scripts import * diff --git a/netbox/extras/forms/misc.py b/netbox/extras/forms/misc.py new file mode 100644 index 000000000..b52338e76 --- /dev/null +++ b/netbox/extras/forms/misc.py @@ -0,0 +1,14 @@ +from django import forms + +__all__ = ( + 'RenderMarkdownForm', +) + + +class RenderMarkdownForm(forms.Form): + """ + Provides basic validation for markup to be rendered. + """ + text = forms.CharField( + required=False + ) diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index b56113ca1..6f29855f5 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -78,8 +78,8 @@ class PluginConfig(AppConfig): def _load_resource(self, name): # Import from the configured path, if defined. - if getattr(self, name): - return import_string(f"{self.__module__}.{self.name}") + if path := getattr(self, name, None): + return import_string(f"{self.__module__}.{path}") # Fall back to the resource's default path. Return None if the module has not been provided. default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}' diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py index e667965b8..288a78512 100644 --- a/netbox/extras/plugins/navigation.py +++ b/netbox/extras/plugins/navigation.py @@ -1,5 +1,6 @@ from netbox.navigation import MenuGroup from utilities.choices import ButtonColorChoices +from django.utils.text import slugify __all__ = ( 'PluginMenu', @@ -21,7 +22,7 @@ class PluginMenu: @property def name(self): - return self.label.replace(' ', '_') + return slugify(self.label) class PluginMenuItem: diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 3c8899b5e..d537b733a 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests): def test_assigned_object_type(self): params = {'assigned_object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk} + params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_assigned_object(self): @@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests): def test_changed_object_type(self): params = {'changed_object_type': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - def test_changed_object_type_id(self): - params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk} + params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f41a45f5a..304e5b9ea 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -92,4 +92,6 @@ urlpatterns = [ path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'), + # Markdown + path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2d2608ae8..91d3b5c58 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q -from django.http import Http404, HttpResponseForbidden +from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View @@ -10,6 +10,7 @@ from rq import Worker from netbox.views import generic from utilities.htmx import is_htmx +from utilities.templatetags.builtins.filters import render_markdown from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables @@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView): queryset = JobResult.objects.all() filterset = filtersets.JobResultFilterSet table = tables.JobResultTable + + +# +# Markdown +# + +class RenderMarkdownView(View): + + def post(self, request): + form = forms.RenderMarkdownForm(request.POST) + if not form.is_valid(): + HttpResponseBadRequest() + rendered = render_markdown(form.cleaned_data['text']) + + return HttpResponse(rendered) diff --git a/netbox/generate_secret_key.py b/netbox/generate_secret_key.py index c3de29cee..21efd0a6d 100755 --- a/netbox/generate_secret_key.py +++ b/netbox/generate_secret_key.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # This script will generate a random 50-character string suitable for use as a SECRET_KEY. import secrets diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 2e9f56bbc..dbda8811f 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import * +from rest_framework import serializers __all__ = ( 'AggregateFilterSet', @@ -599,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.none() return queryset.filter(q) + def parse_inet_addresses(self, value): + ''' + Parse networks or IP addresses and cast to a format + acceptable by the Postgres inet type. + + Skips invalid values. + ''' + parsed = [] + for addr in value: + if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr): + parsed.append(addr) + continue + try: + network = netaddr.IPNetwork(addr) + parsed.append(str(network)) + except (AddrFormatError, ValueError): + continue + return parsed + def filter_address(self, queryset, name, value): + # Let's first parse the addresses passed + # as argument. If they are all invalid, + # we return an empty queryset + value = self.parse_inet_addresses(value) + if (len(value) == 0): + return queryset.none() + try: return queryset.filter(address__net_in=value) except ValidationError: diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index d0af43975..63352698b 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, - SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, + StaticSelect, DynamicModelMultipleChoiceField ) __all__ = ( @@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 430a4b2f8..a34222479 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm): role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP), assigned_object=instance ) + ipaddress.populate_custom_field_defaults() ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index b8f6221bc..0da61ea8a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -27,6 +27,28 @@ __all__ = ( ) +class IPAddressFamilyType(graphene.ObjectType): + + value = graphene.Int() + label = graphene.String() + + def __init__(self, value): + self.value = value + self.label = f'IPv{value}' + + +class BaseIPAddressFamilyType: + ''' + Base type for models that need to expose their IPAddress family type. + ''' + family = graphene.Field(IPAddressFamilyType) + + def resolve_family(self, _): + # Note that self, is an instance of models.IPAddress + # thus resolves to the address family value. + return IPAddressFamilyType(self.family) + + class ASNType(NetBoxObjectType): asn = graphene.Field(BigInt) @@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType): filterset_class = filtersets.ASNFilterSet -class AggregateType(NetBoxObjectType): +class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): class Meta: model = models.Aggregate @@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType): filterset_class = filtersets.FHRPGroupAssignmentFilterSet -class IPAddressType(NetBoxObjectType): +class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType') class Meta: @@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType): return self.role or None -class PrefixType(NetBoxObjectType): +class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): class Meta: model = models.Prefix diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 13b3ae163..c53522d7a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -10,6 +10,7 @@ from ipam.models import * from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup +from rest_framework import serializers class ASNTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -851,6 +852,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'address': ['2001:db8::1/64', '2001:db8::1/65']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + # Check for valid edge cases. Note that Postgres inet type + # only accepts netmasks in the int form, so the filterset + # casts netmasks in the xxx.xxx.xxx.xxx format. + params = {'address': ['24']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + params = {'address': ['10.0.0.1/255.255.255.0']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + # Check for invalid input. + params = {'address': ['/24']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + params = {'address': ['10.0.0.1/255.255.999.0']} # Invalid netmask + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + # Check for partially invalid input. + params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_mask_length(self): params = {'mask_length': '24'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index f041d016d..62482a26f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -216,6 +216,13 @@ class CustomFieldsMixin(models.Model): return dict(groups) + def populate_custom_field_defaults(self): + """ + Apply the default value for each custom field + """ + for cf in self.custom_fields: + self.custom_field_data[cf.name] = cf.default + def clean(self): super().clean() from extras.models import CustomField diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index c88b56072..5ef216259 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -24,7 +24,7 @@ PREFERENCES = { 'pagination.per_page': UserPreference( label=_('Page length'), choices=get_page_lengths(), - description=_('The number of objects to display per page'), + description=_('The default number of objects to display per page'), coerce=lambda x: int(x) ), 'pagination.placement': UserPreference( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2d56a025a..c87303649 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.4.5' +VERSION = '3.4.6' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 519f6021e..66ee787a8 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from typing import Optional +from urllib.parse import quote import django_tables2 as tables from django.conf import settings @@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField from django.template import Context, Template from django.urls import reverse from django.utils.dateparse import parse_date -from django.utils.encoding import escape_uri_path from django.utils.html import escape from django.utils.formats import date_format from django.utils.safestring import mark_safe @@ -235,7 +235,7 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') - url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else '' + url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' html = '' # Compile actions menu diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 8b6b37a4c..74ab785a5 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 27e804c30..f12291e44 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index f0220c050..5d7f7342b 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 19cdae0bd..f430604f9 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index d0563b9fc..753576bc3 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index e677ff599..fe2ccaaef 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; import { initSelectMultiple } from './selectMultiple'; +import { initMarkdownPreviews } from './markdownPreview'; export function initButtons(): void { for (const func of [ @@ -13,6 +14,7 @@ export function initButtons(): void { initSelectAll, initSelectMultiple, initMoveButtons, + initMarkdownPreviews, ]) { func(); } diff --git a/netbox/project-static/src/buttons/markdownPreview.ts b/netbox/project-static/src/buttons/markdownPreview.ts new file mode 100644 index 000000000..224b2beab --- /dev/null +++ b/netbox/project-static/src/buttons/markdownPreview.ts @@ -0,0 +1,45 @@ +import { isTruthy } from 'src/util'; + +/** + * interface for htmx configRequest event + */ +declare global { + interface HTMLElementEventMap { + 'htmx:configRequest': CustomEvent<{ + parameters: Record; + headers: Record; + }>; + } +} + +function initMarkdownPreview(markdownWidget: HTMLDivElement) { + const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement; + const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement; + const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement; + + /** + * Make sure the textarea has style attribute height + * So that it can be copied over to preview div. + */ + if (!isTruthy(textarea.style.height)) { + const { height } = textarea.getBoundingClientRect(); + textarea.style.height = `${height}px`; + } + + /** + * Add the value of the textarea to the body of the htmx request + * and copy the height of text are to the preview div + */ + previewButton.addEventListener('htmx:configRequest', e => { + e.detail.parameters = { text: textarea.value || '' }; + e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN; + preview.style.minHeight = textarea.style.height; + preview.innerHTML = ''; + }); +} + +export function initMarkdownPreviews(): void { + for (const markdownWidget of document.querySelectorAll('.markdown-widget')) { + initMarkdownPreview(markdownWidget); + } +} diff --git a/netbox/project-static/src/tables/interfaceTable.ts b/netbox/project-static/src/tables/interfaceTable.ts index d2b20f322..56a0ae754 100644 --- a/netbox/project-static/src/tables/interfaceTable.ts +++ b/netbox/project-static/src/tables/interfaceTable.ts @@ -1,6 +1,5 @@ import { getElements, replaceAll, findFirstAdjacent } from '../util'; -type InterfaceState = 'enabled' | 'disabled'; type ShowHide = 'show' | 'hide'; function isShowHide(value: unknown): value is ShowHide { @@ -27,54 +26,23 @@ class ButtonState { * Underlying Button DOM Element */ public button: HTMLButtonElement; - /** - * Table rows with `data-enabled` set to `"enabled"` - */ - private enabledRows: NodeListOf; - /** - * Table rows with `data-enabled` set to `"disabled"` - */ - private disabledRows: NodeListOf; - constructor(button: HTMLButtonElement, table: HTMLTableElement) { + /** + * Table rows provided in constructor + */ + private rows: NodeListOf; + + constructor(button: HTMLButtonElement, rows: NodeListOf) { this.button = button; - this.enabledRows = table.querySelectorAll('tr[data-enabled="enabled"]'); - this.disabledRows = table.querySelectorAll('tr[data-enabled="disabled"]'); + this.rows = rows; } /** - * This button's controlled type. For example, a button with the class `toggle-disabled` has - * directive 'disabled' because it controls the visibility of rows with - * `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with - * `data-enabled="enabled"`. + * Remove visibility of button state rows. */ - private get directive(): InterfaceState { - if (this.button.classList.contains('toggle-disabled')) { - return 'disabled'; - } else if (this.button.classList.contains('toggle-enabled')) { - return 'enabled'; - } - // If this class has been instantiated but doesn't contain these classes, it's probably because - // the classes are missing in the HTML template. - console.warn(this.button); - throw new Error('Toggle button does not contain expected class'); - } - - /** - * Toggle visibility of rows with `data-enabled="enabled"`. - */ - private toggleEnabledRows(): void { - for (const row of this.enabledRows) { - row.classList.toggle('d-none'); - } - } - - /** - * Toggle visibility of rows with `data-enabled="disabled"`. - */ - private toggleDisabledRows(): void { - for (const row of this.disabledRows) { - row.classList.toggle('d-none'); + private hideRows(): void { + for (const row of this.rows) { + row.classList.add('d-none'); } } @@ -111,17 +79,6 @@ class ButtonState { } } - /** - * Toggle visibility for the rows this element controls. - */ - private toggleRows(): void { - if (this.directive === 'enabled') { - this.toggleEnabledRows(); - } else if (this.directive === 'disabled') { - this.toggleDisabledRows(); - } - } - /** * Toggle the DOM element's `data-state` attribute. */ @@ -139,17 +96,20 @@ class ButtonState { private toggle(): void { this.toggleState(); this.toggleButton(); - this.toggleRows(); } /** - * When the button is clicked, toggle all controlled elements. + * When the button is clicked, toggle all controlled elements and hide rows based on + * buttonstate. */ public handleClick(event: Event): void { const button = event.currentTarget as HTMLButtonElement; if (button.isEqualNode(this.button)) { this.toggle(); } + if (this.buttonState === 'hide') { + this.hideRows(); + } } } @@ -174,14 +134,25 @@ class TableState { // @ts-expect-error null handling is performed in the constructor private disabledButton: ButtonState; + /** + * Instance of ButtonState for the 'show/hide virtual rows' button. + */ + // @ts-expect-error null handling is performed in the constructor + private virtualButton: ButtonState; + /** * Underlying DOM Table Caption Element. */ private caption: Nullable = null; + /** + * All table rows in table + */ + private rows: NodeListOf; + constructor(table: HTMLTableElement) { this.table = table; - + this.rows = this.table.querySelectorAll('tr'); try { const toggleEnabledButton = findFirstAdjacent( this.table, @@ -191,6 +162,10 @@ class TableState { this.table, 'button.toggle-disabled', ); + const toggleVirtualButton = findFirstAdjacent( + this.table, + 'button.toggle-virtual', + ); const caption = this.table.querySelector('caption'); this.caption = caption; @@ -203,13 +178,28 @@ class TableState { throw new TableStateError("Table is missing a 'toggle-disabled' button.", table); } + if (toggleVirtualButton === null) { + throw new TableStateError("Table is missing a 'toggle-virtual' button.", table); + } + // Attach event listeners to the buttons elements. toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this)); + toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this)); // Instantiate ButtonState for each button for state management. - this.enabledButton = new ButtonState(toggleEnabledButton, this.table); - this.disabledButton = new ButtonState(toggleDisabledButton, this.table); + this.enabledButton = new ButtonState( + toggleEnabledButton, + table.querySelectorAll('tr[data-enabled="enabled"]'), + ); + this.disabledButton = new ButtonState( + toggleDisabledButton, + table.querySelectorAll('tr[data-enabled="disabled"]'), + ); + this.virtualButton = new ButtonState( + toggleVirtualButton, + table.querySelectorAll('tr[data-type="virtual"]'), + ); } catch (err) { if (err instanceof TableStateError) { // This class is useless for tables that don't have toggle buttons. @@ -246,37 +236,42 @@ class TableState { private toggleCaption(): void { const showEnabled = this.enabledButton.buttonState === 'show'; const showDisabled = this.disabledButton.buttonState === 'show'; + const showVirtual = this.virtualButton.buttonState === 'show'; - if (showEnabled && !showDisabled) { + if (showEnabled && !showDisabled && !showVirtual) { this.captionText = 'Showing Enabled Interfaces'; - } else if (showEnabled && showDisabled) { + } else if (showEnabled && showDisabled && !showVirtual) { this.captionText = 'Showing Enabled & Disabled Interfaces'; - } else if (!showEnabled && showDisabled) { + } else if (!showEnabled && showDisabled && !showVirtual) { this.captionText = 'Showing Disabled Interfaces'; - } else if (!showEnabled && !showDisabled) { - this.captionText = 'Hiding Enabled & Disabled Interfaces'; + } else if (!showEnabled && !showDisabled && !showVirtual) { + this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces'; + } else if (!showEnabled && !showDisabled && showVirtual) { + this.captionText = 'Showing Virtual Interfaces'; + } else if (showEnabled && !showDisabled && showVirtual) { + this.captionText = 'Showing Enabled & Virtual Interfaces'; + } else if (showEnabled && showDisabled && showVirtual) { + this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces'; } else { this.captionText = ''; } } /** - * When toggle buttons are clicked, pass the event to the relevant button's handler and update - * this instance's state. + * When toggle buttons are clicked, reapply visability all rows and + * pass the event to all button handlers * * @param event onClick event for toggle buttons. * @param instance Instance of TableState (`this` cannot be used since that's context-specific). */ public handleClick(event: Event, instance: TableState): void { - const button = event.currentTarget as HTMLButtonElement; - const enabled = button.isEqualNode(instance.enabledButton.button); - const disabled = button.isEqualNode(instance.disabledButton.button); - - if (enabled) { - instance.enabledButton.handleClick(event); - } else if (disabled) { - instance.disabledButton.handleClick(event); + for (const row of this.rows) { + row.classList.remove('d-none'); } + + instance.enabledButton.handleClick(event); + instance.disabledButton.handleClick(event); + instance.virtualButton.handleClick(event); instance.toggleCaption(); } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index e486bc7db..37f6c21c4 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -236,12 +236,12 @@ table { } th.asc > a::after { - content: "\f0140"; + content: '\f0140'; font-family: 'Material Design Icons'; } th.desc > a::after { - content: "\f0143"; + content: '\f0143'; font-family: 'Material Design Icons'; } @@ -416,18 +416,18 @@ nav.search { } } -// Styles for the quicksearch and its clear button; +// Styles for the quicksearch and its clear button; // Overrides input-group styles and adds transition effects .quicksearch { - input[type="search"] { - border-radius: $border-radius !important; + input[type='search'] { + border-radius: $border-radius !important; } button { margin-left: -32px !important; z-index: 100 !important; outline: none !important; - border-radius: $border-radius !important; + border-radius: $border-radius !important; transition: visibility 0s, opacity 0.2s linear; } @@ -998,9 +998,24 @@ div.card-overlay { padding: 8px; } +/* Markdown widget */ +.markdown-widget { + .nav-link { + border-bottom: 0; + + &.active { + background-color: var(--nbx-body-bg); + } + } + + .nav-tabs { + background-color: var(--nbx-pre-bg); + } +} + // Preformatted text blocks td pre { - margin-bottom: 0 + margin-bottom: 0; } pre.block { padding: $spacer; diff --git a/netbox/templates/dcim/device/inc/interface_table_controls.html b/netbox/templates/dcim/device/inc/interface_table_controls.html index 14e552439..2b082cfe6 100644 --- a/netbox/templates/dcim/device/inc/interface_table_controls.html +++ b/netbox/templates/dcim/device/inc/interface_table_controls.html @@ -7,5 +7,6 @@ Hide Enabled Hide Disabled + Hide Virtual {% endblock extra_table_controls %} diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 183a8e851..ab882fe7e 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea +from utilities.forms import CommentField, DynamicModelChoiceField __all__ = ( 'ContactBulkEditForm', @@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index bb6c3f73b..ee9543452 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -27,7 +27,7 @@ class CommentField(forms.CharField): """ A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ - widget = forms.Textarea + widget = widgets.MarkdownWidget help_text = f""" diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 9884ffac5..41efbe7b1 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -180,7 +180,7 @@ class ImportForm(BootstrapMixin, forms.Form): if 'data_file' in self.files: self.data_field = 'data_file' file = self.files.get('data_file') - data = file.read().decode('utf-8') + data = file.read().decode('utf-8-sig') else: data = self.cleaned_data['data'] diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 1a2f62b2e..8675f295f 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -195,6 +195,7 @@ def parse_csv(reader): # `site.slug` header, to indicate the related site is being referenced by its slug. for header in next(reader): + header = header.strip() if '.' in header: field, to_field = header.split('.', 1) headers[field] = to_field diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 1802306f1..bd828bb8f 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -16,6 +16,7 @@ __all__ = ( 'ColorSelect', 'DatePicker', 'DateTimePicker', + 'MarkdownWidget', 'NumericArrayField', 'SelectDurationWidget', 'SelectSpeedWidget', @@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput): template_name = 'widgets/select_duration.html' +class MarkdownWidget(forms.Textarea): + template_name = 'widgets/markdown_input.html' + + class NumericArrayField(SimpleArrayField): def clean(self, value): diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index 1f07aa42a..db6326a9c 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -76,8 +76,6 @@ def get_paginate_count(request): if 'per_page' in request.GET: try: per_page = int(request.GET.get('per_page')) - if request.user.is_authenticated: - request.user.config.set('pagination.per_page', per_page, commit=True) return _max_allowed(per_page) except ValueError: pass diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index ec9ceb09a..85c04df92 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -6,7 +6,7 @@ {# Render the field label, except for: #} {# 1. Checkboxes (label appears to the right of the field #} {# 2. Textareas with no label set (will expand across entire row) #} - {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %} + {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %} {% else %} {{ label }} diff --git a/netbox/utilities/templates/widgets/markdown_input.html b/netbox/utilities/templates/widgets/markdown_input.html new file mode 100644 index 000000000..2c71ed289 --- /dev/null +++ b/netbox/utilities/templates/widgets/markdown_input.html @@ -0,0 +1,22 @@ + + + + + Write + + + + + Preview + + + + + + {% include "django/forms/widgets/textarea.html" %} + + + Testing + + + \ No newline at end of file diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index c0836b71d..7f24c86b8 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -17,6 +17,8 @@ from utilities.api import get_graphql_type_for_model from .base import ModelTestCase from .utils import disable_warnings +from ipam.graphql.types import IPAddressFamilyType + __all__ = ( 'APITestCase', @@ -460,6 +462,8 @@ class APIViewTestCases: # TODO: Come up with something more elegant # Temporary hack to support automated testing of reverse generic relations fields_string += f'{field_name} {{ id }}\n' + elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType): + fields_string += f'{field_name} {{ value, label }}\n' else: fields_string += f'{field_name}\n' diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 23c2666df..aec0d896c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -359,18 +359,18 @@ def prepare_cloned_fields(instance): return QueryDict(urlencode(params), mutable=True) -def shallow_compare_dict(source_dict, destination_dict, exclude=None): +def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()): """ Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored. """ difference = {} - for key in destination_dict: - if source_dict.get(key) != destination_dict[key]: - if isinstance(exclude, (list, tuple)) and key in exclude: - continue - difference[key] = destination_dict[key] + for key, value in destination_dict.items(): + if key in exclude: + continue + if source_dict.get(key) != value: + difference[key] = value return difference diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 4463e902a..8f656811a 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -238,7 +238,9 @@ class VirtualMachineFilterSet( return queryset return queryset.filter( Q(name__icontains=value) | - Q(comments__icontains=value) + Q(comments__icontains=value) | + Q(primary_ip4__address__startswith=value) | + Q(primary_ip6__address__startswith=value) ) def _has_primary_ip(self, queryset, name, value): diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 14ae89c37..de68412bd 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect + DynamicModelMultipleChoiceField, StaticSelect ) from virtualization.choices import * from virtualization.models import * @@ -90,7 +90,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) @@ -163,7 +162,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index be54faf9e..69613a8c6 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea +from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -74,7 +74,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -119,7 +118,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) diff --git a/requirements.txt b/requirements.txt index 8bbb80d1e..92f60335a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ bleach==5.0.1 Django==4.1.7 -django-cors-headers==3.13.0 +django-cors-headers==3.14.0 django-debug-toolbar==3.8.1 django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 @@ -8,9 +8,9 @@ django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 -django-rich==1.4.0 +django-rich==1.5.0 django-rq==2.7.0 -django-tables2==2.5.2 +django-tables2==2.5.3 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==9.0.13 +mkdocs-material==9.1.2 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.15.0 +sentry-sdk==1.16.0 social-auth-app-django==5.0.0 social-auth-core[openidconnect]==4.3.0 svgwrite==1.4.3