From 12beac4f1adc5be8dba927f9823e0bd904dcf60f Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Wed, 20 Dec 2023 22:03:00 +0530 Subject: [PATCH 01/22] fix the result of script jobs #14549 --- netbox/extras/management/commands/runscript.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d9a9f41ae..c9cedd3a5 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -114,7 +114,7 @@ class Command(BaseCommand): # Create the job job = Job.objects.create( object=module, - name=script.name, + name=script.class_name, user=User.objects.filter(is_superuser=True).order_by('pk')[0], job_id=uuid.uuid4() ) From 3f4a65cc5cd315a4222dde83a702e77794e0e854 Mon Sep 17 00:00:00 2001 From: Azmodeszer <101867524+Azmodeszer@users.noreply.github.com> Date: Thu, 21 Dec 2023 17:17:18 +0100 Subject: [PATCH 02/22] added ! to safe characters --- netbox/extras/models/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 90e8027b4..74110cf22 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -315,7 +315,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) From c1cf037eafa6be55c729938188a370e5953e725a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markku=20Leini=C3=B6?= Date: Thu, 21 Dec 2023 22:13:40 +0200 Subject: [PATCH 03/22] Print NetBox version in upgrade.sh (#14547) --- upgrade.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/upgrade.sh b/upgrade.sh index cac046a9f..27f3e3d46 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -7,6 +7,10 @@ # Python 3.8 or later. cd "$(dirname "$0")" + +NETBOX_VERSION="$(grep ^VERSION netbox/netbox/settings.py | cut -d\' -f2)" +echo "You are installing (or upgrading to) NetBox version ${NETBOX_VERSION}" + VIRTUALENV="$(pwd -P)/venv" PYTHON="${PYTHON:-python3}" From 8dfec7e2b29d8c6ff8a1a1a61becf69a8f888a00 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 21 Dec 2023 14:40:57 -0600 Subject: [PATCH 04/22] Closes #14538 - Add available_at_site filter (#14541) * Closes #14538 - Add available_at_site filter * Add tests * Fix tests --- netbox/ipam/filtersets.py | 8 ++++++++ netbox/ipam/querysets.py | 29 ++++++++++++++++++++++++++++ netbox/ipam/tests/test_filtersets.py | 13 +++++++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index ba944e3ad..8a65defff 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -950,6 +950,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): choices=VLANStatusChoices, null_value=None ) + available_at_site = django_filters.ModelChoiceFilter( + queryset=Site.objects.all(), + method='get_for_site' + ) available_on_device = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), method='get_for_device' @@ -984,6 +988,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): pass return queryset.filter(qs_filter) + @extend_schema_field(OpenApiTypes.STR) + def get_for_site(self, queryset, name, value): + return queryset.get_for_site(value) + @extend_schema_field(OpenApiTypes.STR) def get_for_device(self, queryset, name, value): return queryset.get_for_device(value) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 39da0c3a2..2ff8a8b6e 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet): class VLANQuerySet(RestrictedQuerySet): + def get_for_site(self, site): + """ + Return all VLANs in the specified site + """ + from .models import VLANGroup + q = Q() + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'), + scope_id=site.pk + ) + + if site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=site.region.get_ancestors(include_self=True) + ) + if site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=site.group.get_ancestors(include_self=True) + ) + + return self.filter( + Q(group__in=VLANGroup.objects.filter(q)) | + Q(site=site) | + Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs + Q(group__isnull=True, site__isnull=True) # Global VLANs + ) + def get_for_device(self, device): """ Return all VLANs available to the specified Device. diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 952376056..8d0b0113a 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1359,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VLANGroup(name='VLAN Group 1', slug='vlan-group-1'), VLANGroup(name='VLAN Group 2', slug='vlan-group-2'), VLANGroup(name='VLAN Group 3', slug='vlan-group-3'), + VLANGroup(name='VLAN Group 4', slug='vlan-group-4'), ) VLANGroup.objects.bulk_create(groups) @@ -1415,6 +1416,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED), + # Create one globally available VLAN on a VLAN group + VLAN(vid=500, name='VLAN Group 1', group=groups[24]), + # Create one globally available VLAN VLAN(vid=1000, name='Global VLAN'), ) @@ -1488,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): def test_available_on_device(self): device_id = Device.objects.first().pk params = {'available_on_device': device_id} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global def test_available_on_virtualmachine(self): vm_id = VirtualMachine.objects.first().pk params = {'available_on_virtualmachine': vm_id} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global + + def test_available_at_site(self): + site_id = Site.objects.first().pk + params = {'available_at_site': site_id} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): From f0b9008529a5f014e718b2b8dd1e9cc8f6672b47 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2023 15:50:15 -0500 Subject: [PATCH 05/22] Fixes #14575: Fix display of the tags column under VDC table --- netbox/dcim/tables/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b72c37daa..f786ae0d9 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1078,7 +1078,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable): comments = columns.MarkdownColumn() tags = columns.TagColumn( - url_name='dcim:vdc_list' + url_name='dcim:virtualdevicecontext_list' ) class Meta(NetBoxTable.Meta): From e5c565cbf4740b9f48133ff7dd4a102ef0cf5181 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2023 16:26:20 -0500 Subject: [PATCH 06/22] Closes #14119: Remove redundant check for to_objectchange() --- netbox/extras/signals.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d6550309f..b5a55ccfa 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -62,21 +62,20 @@ def handle_changed_object(sender, instance, **kwargs): else: return - # Record an ObjectChange if applicable - if hasattr(instance, 'to_objectchange'): - if m2m_changed: - ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk, - request_id=request.id - ).update( - postchange_data=instance.to_objectchange(action).postchange_data - ) - else: - objectchange = instance.to_objectchange(action) - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() + # Record an ObjectChange + if m2m_changed: + ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk, + request_id=request.id + ).update( + postchange_data=instance.to_objectchange(action).postchange_data + ) + else: + objectchange = instance.to_objectchange(action) + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() # If this is an M2M change, update the previously queued webhook (from post_save) queue = webhooks_queue.get() From 169207058f8db4b297fabaedf0a476b7049f7e58 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 21 Dec 2023 14:33:22 -0600 Subject: [PATCH 07/22] Update search to add note --- docs/features/search.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/features/search.md b/docs/features/search.md index 07394af97..92422cad9 100644 --- a/docs/features/search.md +++ b/docs/features/search.md @@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models. +!!! note + NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection"). + ## Saved Filters Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use. From c289dda649b04c9078edbfdfc33e7fa8ffb8b2a6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2023 16:36:24 -0500 Subject: [PATCH 08/22] Changelog for #14507, #14538, #14549, #14560, #14575 --- docs/release-notes/version-3.6.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index fc2328897..44478b899 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -2,6 +2,17 @@ ## v3.6.8 (FUTURE) +### Enhancements + +* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script +* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs + +### Bug Fixes + +* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command +* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs +* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table + --- ## v3.6.7 (2023-12-15) From 0d08205ab13c9eb9fb18ba8546764ce7af26f9fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 21 Dec 2023 16:20:13 -0500 Subject: [PATCH 09/22] Fixes #14532: Device/VM change record should accurately reflect when primary/OOB IP is deleted --- netbox/ipam/signals.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py index 2a985c294..3b36b561f 100644 --- a/netbox/ipam/signals.py +++ b/netbox/ipam/signals.py @@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs): """ field_name = f'primary_ip{instance.family}' if device := Device.objects.filter(**{field_name: instance}).first(): + device.snapshot() + setattr(device, field_name, None) device.save() if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first(): + virtualmachine.snapshot() + setattr(virtualmachine, field_name, None) virtualmachine.save() @@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs): When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP. """ if device := Device.objects.filter(oob_ip=instance).first(): + device.snapshot() + device.oob_ip = None device.save() From 99467e8f66f29338f8c46b4113b42ddcb4c34718 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Dec 2023 10:01:05 -0500 Subject: [PATCH 10/22] Fixes #12731: Support custom validation for many-to-many fields (#14516) * WIP * Enforce custom validators during bulk edit * Add bulk edit M2M validation test * Clean up tests * Add custom validation test for bulk import * Misc cleanup --- netbox/extras/tests/test_custom_validation.py | 265 ++++++++++++++++++ netbox/extras/validators.py | 26 +- netbox/netbox/api/serializers/base.py | 13 +- netbox/netbox/forms/base.py | 11 + netbox/netbox/views/generic/bulk_views.py | 8 + 5 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 netbox/extras/tests/test_custom_validation.py diff --git a/netbox/extras/tests/test_custom_validation.py b/netbox/extras/tests/test_custom_validation.py new file mode 100644 index 000000000..e375b49f5 --- /dev/null +++ b/netbox/extras/tests/test_custom_validation.py @@ -0,0 +1,265 @@ +from django.test import TestCase +from django.test import override_settings + +from circuits.api.serializers import ProviderSerializer +from circuits.forms import ProviderForm +from circuits.models import Provider +from ipam.models import ASN, RIR +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices +from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data + + +class ModelFormCustomValidationTest(TestCase): + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'tags': {'required': True}} + ] + }) + def test_tags_validation(self): + """ + Check that custom validation rules work for tag assignment. + """ + data = { + 'name': 'Provider 1', + 'slug': 'provider-1', + } + form = ProviderForm(data) + self.assertFalse(form.is_valid()) + + tags = create_tags('Tag1', 'Tag2', 'Tag3') + data['tags'] = [tag.pk for tag in tags] + form = ProviderForm(data) + self.assertTrue(form.is_valid()) + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'asns': {'required': True}} + ] + }) + def test_m2m_validation(self): + """ + Check that custom validation rules work for many-to-many fields. + """ + data = { + 'name': 'Provider 1', + 'slug': 'provider-1', + } + form = ProviderForm(data) + self.assertFalse(form.is_valid()) + + rir = RIR.objects.create(name='RIR 1', slug='rir-1') + asns = ASN.objects.bulk_create(( + ASN(rir=rir, asn=65001), + ASN(rir=rir, asn=65002), + ASN(rir=rir, asn=65003), + )) + data['asns'] = [asn.pk for asn in asns] + form = ProviderForm(data) + self.assertTrue(form.is_valid()) + + +class BulkEditCustomValidationTest(ModelViewTestCase): + model = Provider + + @classmethod + def setUpTestData(cls): + rir = RIR.objects.create(name='RIR 1', slug='rir-1') + asns = ASN.objects.bulk_create(( + ASN(rir=rir, asn=65001), + ASN(rir=rir, asn=65002), + ASN(rir=rir, asn=65003), + )) + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + for provider in providers: + provider.asns.set(asns) + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'asns': {'required': True}} + ] + }) + def test_bulk_edit_without_m2m(self): + """ + Check that custom validation rules do not interfere with bulk editing. + """ + data = { + 'pk': list(Provider.objects.values_list('pk', flat=True)), + '_apply': '', + 'description': 'New description', + } + self.add_permissions( + 'circuits.view_provider', + 'circuits.change_provider', + ) + + # Bulk edit the description without changing ASN assignments + request = { + 'path': self._get_url('bulk_edit'), + 'data': post_data(data), + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertEqual( + Provider.objects.filter(description=data['description']).count(), + len(data['pk']) + ) + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'asns': {'required': True}} + ] + }) + def test_bulk_edit_m2m(self): + """ + Test that custom validation rules are enforced during bulk editing. + """ + data = { + 'pk': list(Provider.objects.values_list('pk', flat=True)), + '_apply': '', + 'description': 'New description', + } + self.add_permissions( + 'circuits.view_provider', + 'circuits.change_provider', + 'ipam.view_asn', + ) + + # Change the ASN assignments + asn = ASN.objects.first() + data['asns'] = [asn.pk] + request = { + 'path': self._get_url('bulk_edit'), + 'data': post_data(data), + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + for provider in Provider.objects.all(): + self.assertEqual(len(provider.asns.all()), 1) + + # Attempt to remove the ASN assignments + data.pop('asns') + data['_nullify'] = 'asns' + request = { + 'path': self._get_url('bulk_edit'), + 'data': post_data(data), + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + for provider in Provider.objects.all(): + self.assertTrue(provider.asns.exists()) + + +class BulkImportCustomValidationTest(ModelViewTestCase): + model = Provider + + @classmethod + def setUpTestData(cls): + create_tags('Tag1', 'Tag2', 'Tag3') + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'tags': {'required': True}} + ] + }) + def test_bulk_import_invalid(self): + """ + Test that custom validation rules are enforced during bulk import. + """ + csv_data = ( + "name,slug", + "Provider 1,provider-1", + "Provider 2,provider-2", + "Provider 3,provider-3", + ) + data = { + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.COMMA, + } + self.add_permissions( + 'circuits.view_provider', + 'circuits.add_provider', + 'extras.view_tag', + ) + + # Attempt to import providers without tags + request = { + 'path': self._get_url('import'), + 'data': post_data(data), + } + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + self.assertFalse(Provider.objects.exists()) + + # Import providers successfully with tag assignments + csv_data = ( + "name,slug,tags", + "Provider 1,provider-1,tag1", + "Provider 2,provider-2,tag2", + "Provider 3,provider-3,tag3", + ) + data['data'] = '\n'.join(csv_data) + request = { + 'path': self._get_url('import'), + 'data': post_data(data), + } + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + self.assertTrue(Provider.objects.exists()) + + +class APISerializerCustomValidationTest(APITestCase): + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'tags': {'required': True}} + ] + }) + def test_tags_validation(self): + """ + Check that custom validation rules work for tag assignment. + """ + data = { + 'name': 'Provider 1', + 'slug': 'provider-1', + } + serializer = ProviderSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + tags = create_tags('Tag1', 'Tag2', 'Tag3') + data['tags'] = [tag.pk for tag in tags] + serializer = ProviderSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + @override_settings(CUSTOM_VALIDATORS={ + 'circuits.provider': [ + {'asns': {'required': True}} + ] + }) + def test_m2m_validation(self): + """ + Check that custom validation rules work for many-to-many fields. + """ + data = { + 'name': 'Provider 1', + 'slug': 'provider-1', + } + serializer = ProviderSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + rir = RIR.objects.create(name='RIR 1', slug='rir-1') + asns = ASN.objects.bulk_create(( + ASN(rir=rir, asn=65001), + ASN(rir=rir, asn=65002), + ASN(rir=rir, asn=65003), + )) + data['asns'] = [asn.pk for asn in asns] + serializer = ProviderSerializer(data=data) + self.assertTrue(serializer.is_valid()) diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 686c9b032..366d3a426 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,5 +1,6 @@ -from django.core.exceptions import ValidationError from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ # NOTE: As this module may be imported by configuration.py, we cannot import # anything from NetBox itself. @@ -66,8 +67,7 @@ class CustomValidator: def __call__(self, instance): # Validate instance attributes per validation rules for attr_name, rules in self.validation_rules.items(): - assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}" - attr = getattr(instance, attr_name) + attr = self._getattr(instance, attr_name) for descriptor, value in rules.items(): validator = self.get_validator(descriptor, value) try: @@ -79,6 +79,26 @@ class CustomValidator: # Execute custom validation logic (if any) self.validate(instance) + @staticmethod + def _getattr(instance, name): + # Attempt to resolve many-to-many fields to their stored values + m2m_fields = [f.name for f in instance._meta.local_many_to_many] + if name in m2m_fields: + if name in getattr(instance, '_m2m_values', []): + return instance._m2m_values[name] + if instance.pk: + return list(getattr(instance, name).all()) + return [] + + # Raise a ValidationError for unknown attributes + if not hasattr(instance, name): + raise ValidationError(_('Invalid attribute "{name}" for {model}').format( + name=name, + model=instance.__class__.__name__ + )) + + return getattr(instance, name) + def get_validator(self, descriptor, value): """ Instantiate and return the appropriate validator based on the descriptor given. For diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 5ee74bf8c..d513c8000 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer): validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144) """ def validate(self, data): - - # Remove custom fields data and tags (if any) prior to model validation attrs = data.copy() + + # Remove custom field data (if any) prior to model validation attrs.pop('custom_fields', None) - attrs.pop('tags', None) # Skip ManyToManyFields - for field in self.Meta.model._meta.get_fields(): - if isinstance(field, ManyToManyField): - attrs.pop(field.name, None) + m2m_values = {} + for field in self.Meta.model._meta.local_many_to_many: + if field.name in attrs: + m2m_values[field.name] = attrs.pop(field.name) # Run clean() on an instance of the model if self.instance is None: @@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer): instance = self.instance for k, v in attrs.items(): setattr(instance, k, v) + instance._m2m_values = m2m_values instance.full_clean() return data diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 51e664a39..070a5d26c 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, return super().clean() + def _post_clean(self): + """ + Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance. + """ + self.instance._m2m_values = {} + for field in self.instance._meta.local_many_to_many: + if field.name in self.cleaned_data: + self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name]) + + return super()._post_clean() + class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): """ diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c5a08c80a..69bb85c41 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -557,6 +557,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): elif name in form.changed_data: obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name]) + # Store M2M values for validation + obj._m2m_values = {} + for field in obj._meta.local_many_to_many: + if value := form.cleaned_data.get(field.name): + obj._m2m_values[field.name] = list(value) + elif field.name in nullified_fields: + obj._m2m_values[field.name] = [] + obj.full_clean() obj.save() updated_objects.append(obj) From 43909ee33f44b97600dcceb2c09754fe7793e39c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Dec 2023 10:32:06 -0500 Subject: [PATCH 11/22] Fixes #13649: Permit zero-length cables --- .../migrations/0182_zero_length_cable_fix.py | 22 +++++++++++++++++++ netbox/dcim/models/cables.py | 2 +- netbox/dcim/svg/cables.py | 4 ++-- netbox/templates/dcim/cable.html | 2 +- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 netbox/dcim/migrations/0182_zero_length_cable_fix.py diff --git a/netbox/dcim/migrations/0182_zero_length_cable_fix.py b/netbox/dcim/migrations/0182_zero_length_cable_fix.py new file mode 100644 index 000000000..080e00717 --- /dev/null +++ b/netbox/dcim/migrations/0182_zero_length_cable_fix.py @@ -0,0 +1,22 @@ +from django.db import migrations + + +def update_cable_lengths(apps, schema_editor): + Cable = apps.get_model('dcim', 'Cable') + + # Set the absolute length for any zero-length Cables + Cable.objects.filter(length=0).update(_abs_length=0) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0181_rename_device_role_device_role'), + ] + + operations = [ + migrations.RunPython( + code=update_cable_lengths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index f240659dd..86b4b9320 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -201,7 +201,7 @@ class Cable(PrimaryModel): _created = self.pk is None # Store the given length (if any) in meters for use in database ordering - if self.length and self.length_unit: + if self.length is not None and self.length_unit: self._abs_length = to_meters(self.length, self.length_unit) else: self._abs_length = None diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index acc4fcad9..85b60ead1 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -274,7 +274,7 @@ class CableTraceSVG: if cable.type: # Include the cable type in the tooltip description.append(cable.get_type_display()) - if cable.length and cable.length_unit: + if cable.length is not None and cable.length_unit: # Include the cable length in the tooltip description.append(f'{cable.length} {cable.get_length_unit_display()}') else: @@ -285,7 +285,7 @@ class CableTraceSVG: description = [] if cable.type: labels.append(cable.get_type_display()) - if cable.length and cable.length_unit: + if cable.length is not None and cable.length_unit: # Include the cable length in the tooltip labels.append(f'{cable.length} {cable.get_length_unit_display()}') diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 535b96977..caa1a9fe0 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -50,7 +50,7 @@ {% trans "Length" %} - {% if object.length %} + {% if object.length is not None %} {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} {{ ''|placeholder }} From 031b7540b39e12c5f2dfaa2a8eaa2cef18b8bc74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Dec 2023 13:35:03 -0500 Subject: [PATCH 12/22] Fixes #13741: Update docs to correctly reflect inventory item uniqueness requirements --- docs/models/dcim/inventoryitem.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index f61586eda..b9029f75c 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional). ### Name -The inventory item's name. Must be unique to the parent device. +The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item). ### Label From 634681a72e9cdb1fefcf5779eceb8ec95ae6c13f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Dec 2023 13:15:23 -0500 Subject: [PATCH 13/22] Fixes #13606: Fix filtering by null for multiselect custom fields --- netbox/extras/models/customfields.py | 4 +--- netbox/extras/tests/test_customfields.py | 13 +++++++------ netbox/utilities/filters.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index f70812bc0..ff887ddeb 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse -from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -571,8 +570,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Multiselect elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - filter_class = filters.MultiValueCharFilter - kwargs['lookup_expr'] = 'has_key' + filter_class = filters.MultiValueArrayFilter # Object elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7ac6b2035..574452a81 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase): choice_set = CustomFieldChoiceSet.objects.create( name='Custom Field Choice Set 1', - extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X')) + extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C')) ) # Integer filtering @@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf7': 'http://a.example.com', 'cf8': 'http://a.example.com', 'cf9': 'A', - 'cf10': ['A', 'X'], + 'cf10': ['A', 'B'], 'cf11': manufacturers[0].pk, 'cf12': [manufacturers[0].pk, manufacturers[3].pk], }), @@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf7': 'http://b.example.com', 'cf8': 'http://b.example.com', 'cf9': 'B', - 'cf10': ['B', 'X'], + 'cf10': ['B', 'C'], 'cf11': manufacturers[1].pk, 'cf12': [manufacturers[1].pk, manufacturers[3].pk], }), @@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase): 'cf7': 'http://c.example.com', 'cf8': 'http://c.example.com', 'cf9': 'C', - 'cf10': ['C', 'X'], + 'cf10': None, 'cf11': manufacturers[2].pk, 'cf12': [manufacturers[2].pk, manufacturers[3].pk], }), @@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase): self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) def test_filter_multiselect(self): - self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2) - self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1) def test_filter_object(self): manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 1bf17beae..72c9124a1 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes __all__ = ( 'ContentTypeFilter', 'MACAddressFilter', + 'MultiValueArrayFilter', 'MultiValueCharFilter', 'MultiValueDateFilter', 'MultiValueDateTimeFilter', @@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): field_class = multivalue_field_factory(forms.TimeField) +@extend_schema_field(OpenApiTypes.STR) +class MultiValueArrayFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.CharField) + + def __init__(self, *args, lookup_expr='contains', **kwargs): + # Set default lookup_expr to 'contains' + super().__init__(*args, lookup_expr=lookup_expr, **kwargs) + + def get_filter_predicate(self, v): + # If filtering for null values, ignore lookup_expr + if v is None: + return {self.field_name: None} + return super().get_filter_predicate(v) + + class MACAddressFilter(django_filters.CharFilter): pass From a67236fc3c2d6d6dffc606cad720fbeca14d7e19 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 26 Dec 2023 14:29:09 -0500 Subject: [PATCH 14/22] Fixes #13812: Record data source sync failure when run via syncdatasource command --- netbox/core/management/commands/syncdatasource.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/netbox/core/management/commands/syncdatasource.py b/netbox/core/management/commands/syncdatasource.py index 3d73f70ab..aa8137952 100644 --- a/netbox/core/management/commands/syncdatasource.py +++ b/netbox/core/management/commands/syncdatasource.py @@ -1,5 +1,6 @@ from django.core.management.base import BaseCommand, CommandError +from core.choices import DataSourceStatusChoices from core.models import DataSource @@ -33,9 +34,13 @@ class Command(BaseCommand): for i, datasource in enumerate(datasources, start=1): self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='') self.stdout.flush() - datasource.sync() - self.stdout.write(datasource.get_status_display()) - self.stdout.flush() + try: + datasource.sync() + self.stdout.write(datasource.get_status_display()) + self.stdout.flush() + except Exception as e: + DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) + raise e if len(options['name']) > 1: self.stdout.write(f"Finished.") From e6642b5f5b1fc868cef95cb508eb666996872a8e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 09:44:15 -0500 Subject: [PATCH 15/22] Fixes #11816: Detach group/site validation error from group field --- netbox/ipam/models/vlans.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a57..d2365aa37 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -224,11 +224,11 @@ class VLAN(PrimaryModel): # Validate VLAN group (if assigned) if self.group and self.site and self.group.scope != self.site: - raise ValidationError({ - 'group': _( + raise ValidationError( + _( "VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}." ).format(group=self.group, scope=self.group.scope, site=self.site) - }) + ) # Validate group min/max VIDs if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: From d6c8d1581c665f2a6ae05c338ce3f94747732a30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 11:49:13 -0500 Subject: [PATCH 16/22] Closes #11039: List parent prefixes under IP range view --- netbox/ipam/views.py | 20 ++++++++++++++++++++ netbox/templates/ipam/iprange.html | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1de53b6d2..5fc4301bb 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -661,6 +661,26 @@ class IPRangeListView(generic.ObjectListView): class IPRangeView(generic.ObjectView): queryset = IPRange.objects.all() + def get_extra_context(self, request, instance): + + # Parent prefixes table + parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( + Q(prefix__net_contains_or_equals=str(instance.start_address.ip)), + Q(prefix__net_contains_or_equals=str(instance.end_address.ip)), + vrf=instance.vrf + ).prefetch_related( + 'site', 'role', 'tenant', 'vlan', 'role' + ) + parent_prefixes_table = tables.PrefixTable( + list(parent_prefixes), + exclude=('vrf', 'utilization'), + orderable=False + ) + + return { + 'parent_prefixes_table': parent_prefixes_table, + } + @register_model_view(IPRange, 'ipaddresses', path='ip-addresses') class IPRangeIPAddressesView(generic.ObjectChildrenView): diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index 3e79e6690..13bfe4902 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,6 +82,11 @@ {% plugin_right_page object %} +
+
+ {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %} +
+
{% plugin_full_width_page object %} From b955751349383dbd0b36f4308de8e78466f8f2d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 13:42:26 -0500 Subject: [PATCH 17/22] Fixes #14517: Ensure reservations tab is always displayed under rack view --- docs/release-notes/version-3.6.md | 1 + netbox/dcim/views.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 44478b899..5b666fa86 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -9,6 +9,7 @@ ### Bug Fixes +* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view * [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command * [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs * [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c67dfaade..6d549c49d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -695,8 +695,7 @@ class RackRackReservationsView(generic.ObjectChildrenView): label=_('Reservations'), badge=lambda obj: obj.reservations.count(), permission='dcim.view_rackreservation', - weight=510, - hide_if_empty=True + weight=510 ) def get_children(self, request, parent): From cc0fc03ec3bd64e6333f7973d41fc82c2c9b8ef2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 13:45:06 -0500 Subject: [PATCH 18/22] Changelog for #11039, #11816, #12731, #13606, #13649, #13812, #14532 --- docs/release-notes/version-3.6.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 5b666fa86..1b05c7f9e 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -4,12 +4,19 @@ ### Enhancements +* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view * [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script * [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs ### Bug Fixes +* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment +* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields +* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null +* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables +* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command * [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view +* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted * [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command * [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs * [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table From 8a237561ef73c782d8cc269161c09c974b1b5a4d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 13:49:39 -0500 Subject: [PATCH 19/22] Closes #14596: Match against description field when searching for devices --- docs/release-notes/version-3.6.md | 1 + netbox/dcim/filtersets.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 1b05c7f9e..ce207ddc6 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -7,6 +7,7 @@ * [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view * [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script * [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs +* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices ### Bug Fixes diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b5bdaf269..9f4359764 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1018,6 +1018,7 @@ class DeviceFilterSet( Q(serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) | Q(asset_tag__icontains=value.strip()) | + Q(description_icontains=value.strip()) | Q(comments__icontains=value) | Q(primary_ip4__address__startswith=value) | Q(primary_ip6__address__startswith=value) From 113c60a44af7fa6ad61a8034b8bfc7867fb78660 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 14:20:30 -0500 Subject: [PATCH 20/22] Fixes #13909: Ignore empty choices when populating dynamic choice fields from initial data --- netbox/utilities/forms/fields/dynamic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index 94870451d..00a1f823e 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField): if data is not None: self.choices = [ - choice for choice in self.choices if choice[0] in data + choice for choice in self.choices if choice[0] and choice[0] in data ] return bound_field From 0613e8e95caa1008f9f68818158a44bd5ff8eb34 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 15:13:23 -0500 Subject: [PATCH 21/22] Fixes #14613: Fix display of current configuration parameters --- netbox/core/views.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/netbox/core/views.py b/netbox/core/views.py index e3c1a67aa..0d18371e1 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -1,4 +1,5 @@ from django.contrib import messages +from django.core.cache import cache from django.shortcuts import get_object_or_404, redirect from extras.models import ConfigRevision @@ -153,9 +154,11 @@ class ConfigView(generic.ObjectView): queryset = ConfigRevision.objects.all() def get_object(self, **kwargs): - if config := self.queryset.first(): - return config - # Instantiate a dummy default config if none has been created yet - return ConfigRevision( - data=get_config().defaults - ) + revision_id = cache.get('config_version') + try: + return ConfigRevision.objects.get(pk=revision_id) + except ConfigRevision.DoesNotExist: + # Fall back to using the active config data if no record is found + return ConfigRevision( + data=get_config() + ) From 07da3f6d3366652c425b72e398def0a5a5a9412c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 Dec 2023 16:00:16 -0500 Subject: [PATCH 22/22] Release v3.6.8 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.6.md | 4 +++- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 974527bd3..ed29534f6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -23,7 +23,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.6.7 + placeholder: v3.6.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 9fb14742a..330f3b2bb 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.6.7 + placeholder: v3.6.8 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index ce207ddc6..952319488 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,6 +1,6 @@ # NetBox v3.6 -## v3.6.8 (FUTURE) +## v3.6.8 (2023-12-27) ### Enhancements @@ -16,11 +16,13 @@ * [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null * [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables * [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command +* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field * [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view * [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted * [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command * [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs * [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table +* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI --- diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3fd7f1122..5941ffec5 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.6.8-dev' +VERSION = '3.6.8' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index b2771b445..6cc9089ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ django-pglocks==1.0.4 django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.8.0 -django-rq==2.9.0 +django-rq==2.10.1 django-tables2==2.7.0 django-taggit==4.0.0 django-timezone-field==6.1.0 @@ -21,11 +21,11 @@ graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.5.2 +mkdocs-material==9.5.3 mkdocstrings[python-legacy]==0.24.0 netaddr==0.9.0 Pillow==10.1.0 -psycopg[binary,pool]==3.1.15 +psycopg[binary,pool]==3.1.16 PyYAML==6.0.1 requests==2.31.0 sentry-sdk==1.39.1