Jelajahi Sumber

Merge branch 'develop' into feature

jeremystretch 3 tahun lalu
induk
melakukan
c3dcd8937f

+ 20 - 0
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)

+ 12 - 0
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

+ 14 - 1
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()

+ 1 - 0
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()

+ 18 - 0
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}

+ 2 - 4
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

+ 51 - 0
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})

+ 2 - 1
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
 

+ 0 - 14
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({

+ 1 - 3
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()

+ 4 - 0
netbox/netbox/settings.py

@@ -440,6 +440,10 @@ EXEMPT_PATHS = (
     f'/{BASE_PATH}metrics',
 )
 
+SERIALIZATION_MODULES = {
+    'json': 'utilities.serializers.json',
+}
+
 
 #
 # Sentry

+ 6 - 0
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)
 

+ 2 - 1
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):

+ 9 - 4
netbox/templates/dcim/device.html

@@ -283,10 +283,15 @@
                                             <td style="padding-left: 20px">Leg {{ leg.name }}</td>
                                             <td>{{ leg.outlet_count }}</td>
                                             <td>{{ leg.allocated }}</td>
-                                            <td>{{ powerfeed.available_power|divide:3 }}VA</td>
-                                            {% with phase_available=powerfeed.available_power|divide:3 %}
-                                                <td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
-                                            {% endwith %}
+                                            {% if powerfeed.available_power %}
+                                                {% with phase_available=powerfeed.available_power|divide:3 %}
+                                                    <td>{{ phase_available }}VA</td>
+                                                    <td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
+                                                {% endwith %}
+                                            {% else %}
+                                                <td class="text-muted">&mdash;</td>
+                                                <td class="text-muted">&mdash;</td>
+                                            {% endif %}
                                         </tr>
                                     {% endfor %}
                                 {% endwith %}

+ 8 - 0
netbox/users/apps.py

@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class UsersConfig(AppConfig):
+    name = 'users'
+
+    def ready(self):
+        import users.signals

+ 10 - 0
netbox/users/signals.py

@@ -0,0 +1,10 @@
+import logging
+from django.dispatch import receiver
+from django.contrib.auth.signals import user_login_failed
+
+
+@receiver(user_login_failed)
+def log_user_login_failed(sender, credentials, request, **kwargs):
+    logger = logging.getLogger('netbox.auth.login')
+    username = credentials.get("username")
+    logger.info(f"Failed login attempt for username: {username}")

+ 1 - 1
netbox/users/views.py

@@ -107,7 +107,7 @@ class LoginView(View):
             return self.redirect_to_next(request, logger)
 
         else:
-            logger.debug("Login form validation failed")
+            logger.debug(f"Login form validation failed for username: {form['username'].value()}")
 
         return render(request, self.template_name, {
             'form': form,

+ 3 - 4
netbox/utilities/custom_inspectors.py

@@ -28,13 +28,12 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
         serializer = super().get_request_serializer()
 
         if serializer is not None and self.method in self.implicit_body_methods:
-            writable_class = self.get_writable_class(serializer)
-            if writable_class is not None:
+            if writable_class := self.get_writable_class(serializer):
                 if hasattr(serializer, 'child'):
                     child_serializer = self.get_writable_class(serializer.child)
-                    serializer = writable_class(child=child_serializer)
+                    serializer = writable_class(context=serializer.context, child=child_serializer)
                 else:
-                    serializer = writable_class()
+                    serializer = writable_class(context=serializer.context)
         return serializer
 
     def get_writable_class(self, serializer):

+ 19 - 0
netbox/utilities/serializers/json.py

@@ -0,0 +1,19 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.serializers.json import Serializer as Serializer_
+from django.utils.encoding import is_protected_type
+
+
+class Serializer(Serializer_):
+    """
+    Custom extension of Django's JSON serializer to support ArrayFields (see
+    https://code.djangoproject.com/ticket/33974).
+    """
+    def _value_from_field(self, obj, field):
+        value = field.value_from_object(obj)
+
+        # Handle ArrayFields of protected types
+        if type(field) is ArrayField:
+            if not value or is_protected_type(value[0]):
+                return value
+
+        return value if is_protected_type(value) else field.value_to_string(obj)

+ 8 - 8
netbox/utilities/templates/form_helpers/render_field.html

@@ -8,7 +8,7 @@
             <div class="form-check{% if field.errors %} has-error{% endif %}">
                 {{ field }}
                 <label for="{{ field.id_for_label }}" class="form-check-label">
-                    {{ field.label }}
+                    {{ label }}
                 </label>
             </div>
             {% if field.help_text %}
@@ -23,7 +23,7 @@
         </div>
     </div>
 
-{% elif field|widget_type == 'textarea' and not field.label %}
+{% elif field|widget_type == 'textarea' and not label %}
     <div class="row mb-3">
         {% if label %}
         <label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
@@ -48,7 +48,7 @@
 {% elif field|widget_type == 'slugwidget' %}
     <div class="row mb-3">
         <label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
-            {{ field.label }}
+            {{ label }}
         </label>
         <div class="col">
             <div class="input-group">
@@ -71,13 +71,13 @@
         accept="{{ field.field.widget.attrs.accept }}"
         {% if field.is_required %}required{% endif %}
     />
-    <label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
+    <label for="{{ field.id_for_label }}" class="input-group-text">{{ label|bettertitle }}</label>
   </div>
 
 {% elif field|widget_type == 'clearablefileinput' %}
     <div class="row mb-3">
         <label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
-            {{ field.label }}
+            {{ label }}
         </label>
         <div class="col col-md-9">
             {{ field }}
@@ -87,7 +87,7 @@
 {% elif field|widget_type == 'selectmultiple' %}
     <div class="row mb-3">
         <label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
-            {{ field.label }}
+            {{ label }}
         </label>
         <div class="col col-md-9">
             {{ field }}
@@ -103,7 +103,7 @@
 {% else %}
     <div class="row mb-3">
         <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
-            {{ field.label }}
+            {{ label }}
         </label>
         <div class="col">
             {{ field }}
@@ -112,7 +112,7 @@
             {% endif %}
             <div class="invalid-feedback">
                 {% if field.field.required %}
-                    <strong>{{ field.label }}</strong> field is required.
+                    <strong>{{ label }}</strong> field is required.
                 {% endif %}
             </div>
             {% if bulk_nullable %}

+ 1 - 1
netbox/utilities/templatetags/form_helpers.py

@@ -40,7 +40,7 @@ def render_field(field, bulk_nullable=False, label=None):
     """
     return {
         'field': field,
-        'label': label,
+        'label': label or field.label,
         'bulk_nullable': bulk_nullable,
     }
 

+ 0 - 3
requirements.txt

@@ -34,6 +34,3 @@ tzdata==2022.6
 
 # Workaround for #7401
 jsonschema==3.2.0
-
-# Temporary fix for #10712
-swagger-spec-validator==2.7.6