diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 51c0a5a0d..54f4f7eae 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.6 + placeholder: v3.4.7 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 7c6b4e151..c9bc56ffc 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.6 + placeholder: v3.4.7 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 4781652cd..4a7013a97 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -129,7 +129,8 @@ social-auth-core # Django app for social-auth-core # https://github.com/python-social-auth/social-app-django -social-auth-app-django +# See https://github.com/python-social-auth/social-app-django/issues/429 +social-auth-app-django==5.0.0 # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 07adf5c6a..1fda8d0d3 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated Default: `'netbox.authentication.RemoteUserBackend'` -This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. +This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given. * `netbox.authentication.RemoteUserBackend` * `netbox.authentication.LDAPBackend` diff --git a/docs/configuration/system.md b/docs/configuration/system.md index c9d1e0012..7fbf9ec54 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i * `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) * `PORT` - TCP port to use for the connection (default: `25`) * `USERNAME` - Username with which to authenticate -* `PASSSWORD` - Password with which to authenticate +* `PASSWORD` - Password with which to authenticate * `USE_SSL` - Use SSL when connecting to the server (default: `False`) * `USE_TLS` - Use TLS when connecting to the server (default: `False`) * `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index 6ed66a61d..47f590d59 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,15 +1,31 @@ # NetBox v3.4 -## v3.4.7 (FUTURE) +## v3.4.8 (FUTURE) + +--- + +## v3.4.7 (2023-03-28) ### Enhancements +* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval * [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms +* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set +* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter +* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings +* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces ### Bug Fixes +* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters +* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters * [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type * [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list +* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates +* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form +* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority +* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags +* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts --- diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index ffbf8e10e..1a5257165 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1160,6 +1160,14 @@ class InterfaceBulkEditForm( }, label=_('LAG') ) + vdcs = DynamicModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + label='Virtual Device Contexts', + query_params={ + 'device_id': '$device', + } + ) speed = forms.IntegerField( required=False, widget=SelectSpeedWidget(), @@ -1222,14 +1230,14 @@ class InterfaceBulkEditForm( fieldsets = ( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), - ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( - 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 0c4f5fa60..d596542af 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -12,7 +12,9 @@ from extras.models import ConfigTemplate from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField +from utilities.forms import ( + CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField +) from virtualization.models import Cluster from wireless.choices import WirelessRoleChoices from .common import ModuleCommonForm @@ -691,6 +693,12 @@ class InterfaceImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent LAG interface') ) + vdcs = CSVModelMultipleChoiceField( + queryset=VirtualDeviceContext.objects.all(), + required=False, + to_field_name='name', + help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")' + ) type = CSVChoiceField( choices=InterfaceTypeChoices, help_text=_('Physical medium') @@ -730,7 +738,7 @@ class InterfaceImportForm(NetBoxModelImportForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', - 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) @@ -746,6 +754,7 @@ class InterfaceImportForm(NetBoxModelImportForm): self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) + self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params) def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data @@ -754,6 +763,12 @@ class InterfaceImportForm(NetBoxModelImportForm): else: return self.cleaned_data['enabled'] + def clean_vdcs(self): + for vdc in self.cleaned_data['vdcs']: + if vdc.device != self.cleaned_data['device']: + raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}") + return self.cleaned_data['vdcs'] + class FrontPortImportForm(NetBoxModelImportForm): device = CSVModelChoiceField( diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 6de193043..46f783cb7 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', ] def clean(self): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 0ea8daa8e..7ab7a000e 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,6 +1,7 @@ +import json + from django import forms from django.contrib.contenttypes.models import ContentType -from django.http import QueryDict from django.utils.translation import gettext as _ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup @@ -147,11 +148,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, initial=None, **kwargs): - # Convert any parameters delivered via initial data to a dictionary + # Convert any parameters delivered via initial data to JSON data if initial and 'parameters' in initial: if type(initial['parameters']) is str: - # TODO: Make a utility function for this - initial['parameters'] = dict(QueryDict(initial['parameters']).lists()) + initial['parameters'] = json.loads(initial['parameters']) super().__init__(*args, initial=initial, **kwargs) @@ -277,8 +277,14 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): 'tenants', 'tags', 'data_source', 'data_file', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, initial=None, **kwargs): + + # Convert data delivered via initial data to JSON data + if initial and 'data' in initial: + if type(initial['data']) is str: + initial['data'] = json.loads(initial['data']) + + super().__init__(*args, initial=initial, **kwargs) # Disable data field when a DataFile has been set if self.instance.data_file: diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py index d2ec01006..ed7f49304 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form): help_text=_("Interval at which this report is re-run (in minutes)") ) - def clean_schedule_at(self): + def clean(self): scheduled_time = self.cleaned_data['schedule_at'] - if scheduled_time and scheduled_time < timezone.now(): + if scheduled_time and scheduled_time < local_now(): raise forms.ValidationError(_('Scheduled time must be in the future.')) - return scheduled_time + # When interval is used without schedule at, raise an exception + if self.cleaned_data['interval'] and not scheduled_time: + self.cleaned_data['schedule_at'] = local_now() + + return self.cleaned_data def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 8216c5413..ca7398132 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form): # When interval is used without schedule at, raise an exception if self.cleaned_data['_interval'] and not scheduled_time: - raise forms.ValidationError(_('Scheduled time must be set when recurs is used.')) + self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index bc4e7258d..4eac1409f 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -2,7 +2,6 @@ from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse -from django.utils import timezone from django.utils.translation import gettext as _ from jinja2.loaders import BaseLoader from jinja2.sandbox import SandboxedEnvironment @@ -10,7 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment from extras.querysets import ConfigContextQuerySet from netbox.config import get_config from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, SyncedDataMixin, TagsMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from utilities.jinja2 import ConfigTemplateLoader from utilities.utils import deepmerge @@ -25,7 +24,7 @@ __all__ = ( # Config contexts # -class ConfigContext(SyncedDataMixin, ChangeLoggedModel): +class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B @@ -114,6 +113,12 @@ class ConfigContext(SyncedDataMixin, ChangeLoggedModel): objects = ConfigContextQuerySet.as_manager() + clone_fields = ( + 'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types', + 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', 'data', + ) + class Meta: ordering = ['weight', 'name'] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 61c19bb0d..61dae5c99 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -250,7 +250,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): ) clone_fields = ( - 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', ) class Meta: @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): } -class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel): +class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='export_templates', @@ -318,6 +318,10 @@ class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_("Download file as attachment") ) + clone_fields = ( + 'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment', + ) + class Meta: ordering = ('name',) @@ -417,7 +421,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): parameters = models.JSONField() clone_fields = ( - 'enabled', 'weight', + 'content_types', 'weight', 'enabled', 'parameters', ) class Meta: diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 286ec7423..066c0fd78 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -5,7 +5,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -19,7 +19,7 @@ __all__ = ( # Tags # -class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase): +class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): id = models.BigAutoField( primary_key=True ) @@ -31,6 +31,10 @@ class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) + clone_fields = ( + 'color', 'description', + ) + class Meta: ordering = ['name'] diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 0f1a1a948..861f82882 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,4 @@ +import json from collections import defaultdict from functools import cached_property @@ -115,7 +116,11 @@ class CloningMixin(models.Model): for field_name in getattr(self, 'clone_fields', []): field = self._meta.get_field(field_name) field_value = field.value_from_object(self) - if field_value not in (None, ''): + if field_value and isinstance(field, models.ManyToManyField): + attrs[field_name] = [v.pk for v in field_value] + elif field_value and isinstance(field, models.JSONField): + attrs[field_name] = json.dumps(field_value) + elif field_value not in (None, ''): attrs[field_name] = field_value # Include tags (if applicable) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 906ff4cad..5d1878701 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -394,8 +394,10 @@ TEMPLATES = [ ] # Set up authentication backends +if type(REMOTE_AUTH_BACKEND) not in (list, tuple): + REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] AUTHENTICATION_BACKENDS = [ - REMOTE_AUTH_BACKEND, + *REMOTE_AUTH_BACKEND, 'netbox.authentication.ObjectPermissionBackend', ] diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3af7814ae..2c79ab006 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -141,7 +141,7 @@ {% if object.virtual_chassis.master == vc_member %}{% endif %} - {{ vc_member.vc_priority|default:"" }} + {{ vc_member.vc_priority|placeholder }} {% endfor %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 3c0b10826..fe68a9fc7 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -8,6 +8,7 @@ {% render_field form.name %} {% render_field form.domain %} + {% render_field form.description %} {% render_field form.tags %} diff --git a/requirements.txt b/requirements.txt index d5f495b7a..e12b31216 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ boto3==1.26.91 Django==4.1.7 django-cors-headers==3.14.0 django-debug-toolbar==3.8.1 -django-filter==22.1 +django-filter==23.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 @@ -21,18 +21,18 @@ graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.2 +mkdocs-material==9.1.4 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.16.0 +sentry-sdk==1.18.0 social-auth-app-django==5.0.0 -social-auth-core[openidconnect]==4.3.0 +social-auth-core[openidconnect]==4.4.0 svgwrite==1.4.3 -tablib==3.3.0 -tzdata==2022.7 +tablib==3.4.0 +tzdata==2023.2 # Workaround for #7401 jsonschema==3.2.0