diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 974527bd345..ed29534f669 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 9fb14742aa5..330f3b2bb4a 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/features/search.md b/docs/features/search.md index 07394af97ad..92422cad950 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. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index f61586eda87..b9029f75cf7 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 diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 3478eb0817c..952319488ea 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,31 @@ # NetBox v3.6 +## v3.6.8 (2023-12-27) + +### 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 +* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices + +### 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 +* [#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 + +--- + ## v3.6.7 (2023-12-15) ### Enhancements diff --git a/netbox/core/management/commands/syncdatasource.py b/netbox/core/management/commands/syncdatasource.py index 3d73f70ab5b..aa81379526d 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 @@ def handle(self, *args, **options): 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.") diff --git a/netbox/core/views.py b/netbox/core/views.py index e3c1a67aa36..0d18371e118 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() + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b5bdaf26995..9f435976447 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1018,6 +1018,7 @@ def search(self, queryset, name, value): 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) 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 00000000000..080e0071713 --- /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 f240659dd33..86b4b932068 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -201,7 +201,7 @@ def save(self, *args, **kwargs): _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 acc4fcad9ee..85b60ead166 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -274,7 +274,7 @@ def draw_cable(self, cable, terminations, cable_count=0): 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 @@ def draw_cable(self, cable, terminations, cable_count=0): 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/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b72c37daa4d..f786ae0d951 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): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c67dfaade07..6d549c49d0d 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): diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d9a9f41ae6f..c9cedd3a5c9 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -114,7 +114,7 @@ def _run_script(): # 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() ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index f70812bc075..ff887ddeb97 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -10,7 +10,6 @@ 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 @@ def to_filter(self, lookup_expr=None): # 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/models/models.py b/netbox/extras/models/models.py index 90e8027b452..74110cf228d 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -315,7 +315,7 @@ def render(self, context): 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) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d6550309f46..b5a55ccfaf1 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() diff --git a/netbox/extras/tests/test_custom_validation.py b/netbox/extras/tests/test_custom_validation.py new file mode 100644 index 00000000000..e375b49f58e --- /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/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7ac6b20358d..574452a81c5 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1329,7 +1329,7 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): '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 @@ def setUpTestData(cls): '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 @@ def setUpTestData(cls): '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 @@ def test_filter_select(self): 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/extras/validators.py b/netbox/extras/validators.py index 686c9b032d6..366d3a42628 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 @@ def __init__(self, validation_rules=None): 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 @@ def __call__(self, instance): # 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/ipam/filtersets.py b/netbox/ipam/filtersets.py index ba944e3ada9..8a65deffffa 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 @@ def search(self, queryset, name, value): 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/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a574f..d2365aa3708 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -224,11 +224,11 @@ def clean(self): # 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: diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 39da0c3a229..2ff8a8b6e0e 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -69,6 +69,35 @@ def annotate_utilization(self): 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/signals.py b/netbox/ipam/signals.py index 2a985c2940a..3b36b561f55 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() diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 95237605647..8d0b0113aa1 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1359,6 +1359,7 @@ def setUpTestData(cls): 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 @@ def setUpTestData(cls): 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 @@ def test_tenant_group(self): 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): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1de53b6d221..5fc4301bb54 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/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 5ee74bf8c57..d513c8000d0 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 @@ def validate(self, data): 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 51e664a3956..070a5d26cb2 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -57,6 +57,17 @@ def clean(self): 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/settings.py b/netbox/netbox/settings.py index 9cc6820bd63..5941ffec577 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ # Environment setup # -VERSION = '3.6.7' +VERSION = '3.6.8' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c5a08c80a2d..69bb85c4102 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -557,6 +557,14 @@ def _update_objects(self, form, request): 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) diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 535b969779f..caa1a9fe0c9 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -50,7 +50,7 @@