diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index fb789bd98..e7fe56a09 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w Default: `|` (Pipe) -The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) +The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) --- diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index e2bc53cfc..c68bc21f1 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -390,7 +390,7 @@ class NewBranchScript(Script): name=f'{site.slug}-switch{i}', site=site, status=DeviceStatusChoices.STATUS_PLANNED, - role=switch_role + device_role=switch_role ) switch.full_clean() switch.save() diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 103b0664c..72c15f00c 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -2,6 +2,17 @@ ## v3.7.3 (FUTURE) +### Bug Fixes + +* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table +* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import +* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines +* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views +* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination +* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints +* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API +* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode + --- ## v3.7.2 (2024-02-05) diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index b7e537c23..8eecfa8b9 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -8,6 +8,7 @@ from drf_spectacular.plumbing import ( build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, ) from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from rest_framework.relations import ManyRelatedField from netbox.api.fields import ChoiceField, SerializedPKRelatedField diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f30ff91fa..732bb87ae 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -996,7 +996,7 @@ class DeviceBayImportForm(NetBoxModelImportForm): device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD ).exclude(pk=device.pk) else: - self.fields['installed_device'].queryset = Interface.objects.none() + self.fields['installed_device'].queryset = Device.objects.none() class InventoryItemImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 88dddb312..5b2564b32 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin): super().clean() # Validate that the parent Device can have DeviceBays - if not self.device.device_type.is_parent_device: + if hasattr(self, 'device') and not self.device.device_type.is_parent_device: raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format( device_type=self.device.device_type )) # Cannot install a device into itself, obviously - if self.device == self.installed_device: + if self.installed_device and getattr(self, 'device', None) == self.installed_device: raise ValidationError(_("Cannot install a device into itself.")) # Check that the installed device is not already installed elsewhere diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4b9689a22..f9e8ba213 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -875,7 +875,7 @@ class Device( if self.position and self.device_type.u_height == 0: raise ValidationError({ 'position': _( - "A U0 device type ({device_type}) cannot be assigned to a rack position." + "A 0U device type ({device_type}) cannot be assigned to a rack position." ).format(device_type=self.device_type) }) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 3f8b63688..de27d67ad 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """ INTERFACE_IPADDRESSES = """
{% if value.count >= 3 %} - {{ value.count }} + {{ value.count }} {% else %} {% for ip in value.all %} {% if ip.status != 'active' %} diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4c15e839a..d3c6da060 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -3,6 +3,7 @@ import logging from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ @@ -15,6 +16,7 @@ from extras.models import EventRule from extras.validators import CustomValidator from netbox.config import get_config from netbox.context import current_request, events_queue +from netbox.models.features import ChangeLoggingMixin from netbox.signals import post_clean from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices @@ -68,7 +70,7 @@ def handle_changed_object(sender, instance, **kwargs): else: return - # Create/update an ObejctChange record for this change + # Create/update an ObjectChange record for this change objectchange = instance.to_objectchange(action) # If this is a many-to-many field change, check for a previous ObjectChange instance recorded # for this object by this request and update it @@ -122,6 +124,25 @@ def handle_deleted_object(sender, instance, **kwargs): objectchange.request_id = request.id objectchange.save() + # Django does not automatically send an m2m_changed signal for the reverse direction of a + # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to + # trigger one manually. We do this by checking for any reverse M2M relationships on the + # instance being deleted, and explicitly call .remove() on the remote M2M field to delete + # the association. This triggers an m2m_changed signal with the `post_remove` action type + # for the forward direction of the relationship, ensuring that the change is recorded. + for relation in instance._meta.related_objects: + if type(relation) is not ManyToManyRel: + continue + related_model = relation.related_model + related_field_name = relation.remote_field.name + if not issubclass(related_model, ChangeLoggingMixin): + # We only care about triggering the m2m_changed signal for models which support + # change logging + continue + for obj in related_model.objects.filter(**{related_field_name: instance.pk}): + obj.snapshot() # Ensure the change record includes the "before" state + getattr(obj, related_field_name).remove(instance) + # Enqueue webhooks queue = events_queue.get() enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 17d8d74a7..c012eca6d 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer): class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') + group = NestedFHRPGroupSerializer() class Meta: model = models.FHRPGroupAssignment - fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority'] + fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority'] # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cb633e162..447415a69 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase): class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase): model = FHRPGroupAssignment - brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url'] + brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url'] bulk_update_data = { 'priority': 100, } diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index a13b84bed..6eb2b36e1 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -489,10 +489,10 @@ class SyncedDataMixin(models.Model): # Create/delete AutoSyncRecord as needed content_type = ContentType.objects.get_for_model(self) if self.auto_sync_enabled: - AutoSyncRecord.objects.get_or_create( - datafile=self.data_file, + AutoSyncRecord.objects.update_or_create( object_type=content_type, - object_id=self.pk + object_id=self.pk, + defaults={'datafile': self.data_file} ) else: AutoSyncRecord.objects.filter( diff --git a/netbox/templates/vpn/l2vpntermination_edit.html b/netbox/templates/vpn/l2vpntermination_edit.html index 0df2c883e..cbce78dbc 100644 --- a/netbox/templates/vpn/l2vpntermination_edit.html +++ b/netbox/templates/vpn/l2vpntermination_edit.html @@ -13,7 +13,7 @@
-
+
{% render_field form.vlan %}
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 99320fa25..aa5811cd1 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -385,7 +385,7 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID } model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() - except FieldError as e: + except (FieldError, ValueError) as e: raise forms.ValidationError({ 'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e) }) diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index 879fc02c5..baa1253eb 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -25,7 +25,7 @@
  • - {% trans "Add export template" %}... + {% trans "Add export template" %}...
  • {% endif %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 7ed36388b..1dcb413ec 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -103,8 +103,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): fields = [ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', - 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', - 'interface_count', 'virtual_disk_count', + 'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', + 'last_updated', 'interface_count', 'virtual_disk_count', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index dedcbfbf5..5f6fcd5f7 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -46,7 +46,10 @@ class TunnelSerializer(NetBoxModelSerializer): status = ChoiceField( choices=TunnelStatusChoices ) - group = NestedTunnelGroupSerializer() + group = NestedTunnelGroupSerializer( + required=False, + allow_null=True + ) encapsulation = ChoiceField( choices=TunnelEncapsulationChoices ) diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py index c10985733..bc591c1e6 100644 --- a/netbox/vpn/tables/tunnels.py +++ b/netbox/vpn/tables/tunnels.py @@ -40,6 +40,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + group = tables.Column( + verbose_name=_('Group'), + linkify=True + ) status = columns.ChoiceFieldColumn( verbose_name=_('Status') ) @@ -63,10 +67,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Tunnel fields = ( - 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id', - 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'group', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', + 'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count') + default_columns = ('pk', 'name', 'group', 'status', 'encapsulation', 'tenant', 'terminations_count') class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index eb0520c8b..64c175fe5 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -105,7 +105,6 @@ class TunnelTest(APIViewTestCases.APIViewTestCase): { 'name': 'Tunnel 6', 'status': TunnelStatusChoices.STATUS_DISABLED, - 'group': tunnel_groups[1].pk, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, }, ]