diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a46424df0..ffb3b9ffe 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,26 @@ ## v3.3.9 (FUTURE) +### Enhancements + +* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts + +### Bug Fixes + +* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions +* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log +* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs +* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power +* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK +* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete +* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns +* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination +* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form +* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg +* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports +* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available +* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster + --- ## v3.3.8 (2022-11-16) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c51b59f94..fc9d2d7a1 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -279,6 +279,17 @@ class CableTermination(models.Model): def clean(self): super().clean() + # Check for existing termination + existing_termination = CableTermination.objects.exclude(cable=self.cable).filter( + termination_type=self.termination_type, + termination_id=self.termination_id + ).first() + if existing_termination is not None: + raise ValidationError( + f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} " + f"{self.termination_id}: cable {existing_termination.cable.pk}" + ) + # Validate interface type (if applicable) if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces") @@ -570,6 +581,7 @@ class CablePath(models.Model): [object_to_path_node(circuit_termination)], [object_to_path_node(circuit_termination.provider_network)], ]) + is_complete = True break elif circuit_termination.site and not circuit_termination.cable: # Circuit terminates to a Site diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index b990daf1a..f223f9c5a 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver from .choices import CableEndChoices, LinkStatusChoices -from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .models import ( + Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis, +) from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths @@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs): for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable): cablepath.retrace() + + +@receiver(post_save, sender=FrontPort) +def extend_rearport_cable_paths(instance, created, **kwargs): + """ + When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort. + """ + if created: + rearport = instance.rear_port + for cablepath in CablePath.objects.filter(_nodes__contains=rearport): + cablepath.retrace() diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cfbbbc63b..50a707bc6 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase): is_active=True ) self.assertEqual(CablePath.objects.count(), 1) + self.assertTrue(CablePath.objects.first().is_complete) # Delete cable 1 cable1.delete() diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index cb35b4e73..17e6f77c5 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError from extras.choices import CustomFieldTypeChoices from extras.models import CustomField from netbox.constants import NESTED_SERIALIZER_PREFIX +from utilities.api import get_serializer_for_model # @@ -69,6 +70,23 @@ class CustomFieldsDataField(Field): "values." ) + # Serialize object and multi-object values + for cf in self._get_custom_fields(): + if cf.name in data and cf.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT + ): + serializer_class = get_serializer_for_model( + model=cf.object_type.model_class(), + prefix=NESTED_SERIALIZER_PREFIX + ) + many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT + serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context) + if serializer.is_valid(): + data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id'] + else: + raise ValidationError(f"Unknown related object(s): {data[cf.name]}") + # If updating an existing instance, start with existing custom_field_data if self.parent.instance: data = {**self.parent.instance.custom_field_data, **data} diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 31e0c126c..4972d9e85 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook - # # Change logging/webhooks # @@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs): """ Fires when an object is deleted. """ - if not hasattr(instance, 'to_objectchange'): - return - # Get the current request, or bail if not set request = current_request.get() if request is None: @@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): + if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): + instance.snapshot() objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) objectchange.user = request.user objectchange.request_id = request.id diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 6ca354438..c88e6021d 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -854,6 +854,57 @@ class CustomFieldAPITest(APITestCase): list(original_cfvs['multiobject_field']) ) + def test_specify_related_object_by_attr(self): + site1 = Site.objects.get(name='Site 1') + vlans = VLAN.objects.all()[:3] + url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk}) + self.add_permissions('dcim.change_site') + + # Set related objects by PK + data = { + 'custom_fields': { + 'object_field': vlans[0].pk, + 'multiobject_field': [vlans[1].pk, vlans[2].pk], + }, + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual( + response.data['custom_fields']['object_field']['id'], + vlans[0].pk + ) + self.assertListEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + [vlans[1].pk, vlans[2].pk] + ) + + # Set related objects by name + data = { + 'custom_fields': { + 'object_field': { + 'name': vlans[0].name, + }, + 'multiobject_field': [ + { + 'name': vlans[1].name + }, + { + 'name': vlans[2].name + }, + ], + }, + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual( + response.data['custom_fields']['object_field']['id'], + vlans[0].pk + ) + self.assertListEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + [vlans[1].pk, vlans[2].pk] + ) + def test_minimum_maximum_values_validation(self): site2 = Site.objects.get(name='Site 2') url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3581ba104..430a4b2f8 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -436,7 +436,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): initial['nat_rack'] = nat_inside_parent.device.rack.pk initial['nat_device'] = nat_inside_parent.device.pk elif type(nat_inside_parent) is VMInterface: - initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk + if cluster := nat_inside_parent.virtual_machine.cluster: + initial['nat_cluster'] = cluster.pk initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk kwargs['initial'] = initial diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 20530d66c..e8bf13375 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,7 +9,6 @@ from django.utils.functional import cached_property from django.utils.translation import gettext as _ from dcim.fields import ASNField -from dcim.models import Device from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -18,7 +17,6 @@ from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator from netbox.config import get_config from netbox.models import OrganizationalModel, PrimaryModel -from virtualization.models import VirtualMachine __all__ = ( 'Aggregate', @@ -864,18 +862,6 @@ class IPAddress(PrimaryModel): ) }) - # Check for primary IP assignment that doesn't match the assigned device/VM - if self.pk: - for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')): - parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() - if parent and getattr(self.assigned_object, attr, None) != parent: - # Check for a NAT relationship - if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent: - raise ValidationError({ - 'interface': f"IP address is primary for {cls._meta.model_name} {parent} but " - f"not assigned to it!" - }) - # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: raise ValidationError({ diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index e5e842696..d7e226c04 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali ) def list(self, request, *args, **kwargs): - """ - Overrides ListModelMixin to allow processing ExportTemplates. - """ + # Overrides ListModelMixin to allow processing ExportTemplates. if 'export' in request.GET: content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model) et = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 057816554..4dbf48c36 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -440,6 +440,10 @@ EXEMPT_PATHS = ( f'/{BASE_PATH}metrics', ) +SERIALIZATION_MODULES = { + 'json': 'utilities.serializers.json', +} + # # Sentry diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 5e92196e5..492a64abf 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -425,6 +425,12 @@ class CustomFieldColumn(tables.Column): kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') if 'verbose_name' not in kwargs: kwargs['verbose_name'] = customfield.label or customfield.name + # We can't logically sort on FK values + if customfield.type in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT + ): + kwargs['orderable'] = False super().__init__(*args, **kwargs) diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 738d70786..795f4ad56 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -128,9 +128,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): # Determine the available actions actions = self.get_permitted_actions(request.user, model=self.child_model) + has_bulk_actions = any([a.startswith('bulk_') for a in actions]) table_data = self.prep_table_data(request, child_objects, instance) - table = self.get_table(table_data, request, bool(actions)) + table = self.get_table(table_data, request, has_bulk_actions) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cb7a7133d..083417cd3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -283,10 +283,15 @@