Merge branch 'netbox-community:develop' into develop

This commit is contained in:
Ash Kirby 2024-02-18 20:07:54 +00:00 committed by GitHub
commit 2d7e33df3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 64 additions and 24 deletions

View File

@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
Default: `|` (Pipe) 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` )
--- ---

View File

@ -390,7 +390,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}', name=f'{site.slug}-switch{i}',
site=site, site=site,
status=DeviceStatusChoices.STATUS_PLANNED, status=DeviceStatusChoices.STATUS_PLANNED,
role=switch_role device_role=switch_role
) )
switch.full_clean() switch.full_clean()
switch.save() switch.save()

View File

@ -2,6 +2,17 @@
## v3.7.3 (FUTURE) ## 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) ## v3.7.2 (2024-02-05)

View File

@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
) )
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.relations import ManyRelatedField from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, SerializedPKRelatedField

View File

@ -996,7 +996,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
).exclude(pk=device.pk) ).exclude(pk=device.pk)
else: else:
self.fields['installed_device'].queryset = Interface.objects.none() self.fields['installed_device'].queryset = Device.objects.none()
class InventoryItemImportForm(NetBoxModelImportForm): class InventoryItemImportForm(NetBoxModelImportForm):

View File

@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
super().clean() super().clean()
# Validate that the parent Device can have DeviceBays # 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( raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type device_type=self.device.device_type
)) ))
# Cannot install a device into itself, obviously # 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.")) raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere # Check that the installed device is not already installed elsewhere

View File

@ -875,7 +875,7 @@ class Device(
if self.position and self.device_type.u_height == 0: if self.position and self.device_type.u_height == 0:
raise ValidationError({ raise ValidationError({
'position': _( '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) ).format(device_type=self.device_type)
}) })

View File

@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """ INTERFACE_IPADDRESSES = """
<div class="table-badge-group"> <div class="table-badge-group">
{% if value.count >= 3 %} {% if value.count >= 3 %}
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a> <a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
{% else %} {% else %}
{% for ip in value.all %} {% for ip in value.all %}
{% if ip.status != 'active' %} {% if ip.status != 'active' %}

View File

@ -3,6 +3,7 @@ import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError 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.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -15,6 +16,7 @@ from extras.models import EventRule
from extras.validators import CustomValidator from extras.validators import CustomValidator
from netbox.config import get_config from netbox.config import get_config
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
@ -68,7 +70,7 @@ def handle_changed_object(sender, instance, **kwargs):
else: else:
return return
# Create/update an ObejctChange record for this change # Create/update an ObjectChange record for this change
objectchange = instance.to_objectchange(action) objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded # 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 # 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.request_id = request.id
objectchange.save() 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 # Enqueue webhooks
queue = events_queue.get() queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE) enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)

View File

@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
group = NestedFHRPGroupSerializer()
class Meta: class Meta:
model = models.FHRPGroupAssignment model = models.FHRPGroupAssignment
fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority'] fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
# #

View File

@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase): class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
model = FHRPGroupAssignment 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 = { bulk_update_data = {
'priority': 100, 'priority': 100,
} }

View File

@ -489,10 +489,10 @@ class SyncedDataMixin(models.Model):
# Create/delete AutoSyncRecord as needed # Create/delete AutoSyncRecord as needed
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
if self.auto_sync_enabled: if self.auto_sync_enabled:
AutoSyncRecord.objects.get_or_create( AutoSyncRecord.objects.update_or_create(
datafile=self.data_file,
object_type=content_type, object_type=content_type,
object_id=self.pk object_id=self.pk,
defaults={'datafile': self.data_file}
) )
else: else:
AutoSyncRecord.objects.filter( AutoSyncRecord.objects.filter(

View File

@ -13,7 +13,7 @@
<div class="offset-sm-3"> <div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist"> <ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item"> <li role="presentation" class="nav-item">
<button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}"> <button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}">
{% trans "VLAN" %} {% trans "VLAN" %}
</button> </button>
</li> </li>
@ -32,7 +32,7 @@
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="tab-content p-0 border-0"> <div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab"> <div class="tab-pane {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
{% render_field form.vlan %} {% render_field form.vlan %}
</div> </div>
<div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab"> <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">

View File

@ -385,7 +385,7 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID
} }
model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
except FieldError as e: except (FieldError, ValueError) as e:
raise forms.ValidationError({ raise forms.ValidationError({
'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e) 'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e)
}) })

View File

@ -25,7 +25,7 @@
<hr class="dropdown-divider"> <hr class="dropdown-divider">
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_type={{ content_type.pk }}">{% trans "Add export template" %}...</a> <a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_types={{ content_type.pk }}">{% trans "Add export template" %}...</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -103,8 +103,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [ fields = [
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created',
'interface_count', 'virtual_disk_count', 'last_updated', 'interface_count', 'virtual_disk_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))

View File

@ -46,7 +46,10 @@ class TunnelSerializer(NetBoxModelSerializer):
status = ChoiceField( status = ChoiceField(
choices=TunnelStatusChoices choices=TunnelStatusChoices
) )
group = NestedTunnelGroupSerializer() group = NestedTunnelGroupSerializer(
required=False,
allow_null=True
)
encapsulation = ChoiceField( encapsulation = ChoiceField(
choices=TunnelEncapsulationChoices choices=TunnelEncapsulationChoices
) )

View File

@ -40,6 +40,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Name'), verbose_name=_('Name'),
linkify=True linkify=True
) )
group = tables.Column(
verbose_name=_('Group'),
linkify=True
)
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status') verbose_name=_('Status')
) )
@ -63,10 +67,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Tunnel model = Tunnel
fields = ( fields = (
'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id', 'pk', 'id', 'name', 'group', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group',
'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', '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): class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):

View File

@ -105,7 +105,6 @@ class TunnelTest(APIViewTestCases.APIViewTestCase):
{ {
'name': 'Tunnel 6', 'name': 'Tunnel 6',
'status': TunnelStatusChoices.STATUS_DISABLED, 'status': TunnelStatusChoices.STATUS_DISABLED,
'group': tunnel_groups[1].pk,
'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
}, },
] ]