Sfoglia il codice sorgente

Merge pull request #11059 from netbox-community/develop

Release v3.3.9
Jeremy Stretch 3 anni fa
parent
commit
85c60670dc

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.8
+      placeholder: v3.3.9
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.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.3.8
+      placeholder: v3.3.9
     validations:
       required: true
   - type: dropdown

+ 2 - 2
docs/customization/reports.md

@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
         # Check that every console port for every active device has a connection defined.
         active = DeviceStatusChoices.STATUS_ACTIVE
         for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
-            if console_port.connected_endpoint is None:
+            if not console_port.connected_endpoints:
                 self.log_failure(
                     console_port.device,
                     "No console connection defined for {}".format(console_port.name)
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
         for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
             connected_ports = 0
             for power_port in PowerPort.objects.filter(device=device):
-                if power_port.connected_endpoint is not None:
+                if power_port.connected_endpoints:
                     connected_ports += 1
                     if not power_port.path.is_active:
                         self.log_warning(

+ 27 - 0
docs/release-notes/version-3.3.md

@@ -1,5 +1,32 @@
 # NetBox v3.3
 
+## v3.3.9 (2022-11-30)
+
+### 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
+* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
+* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
+* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
+
+---
+
 ## v3.3.8 (2022-11-16)
 
 ### Enhancements

+ 2 - 2
netbox/dcim/forms/bulk_edit.py

@@ -1218,7 +1218,7 @@ class FrontPortBulkEditForm(
     fieldsets = (
         (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
     )
-    nullable_fields = ('module', 'label', 'description')
+    nullable_fields = ('module', 'label', 'description', 'color')
 
 
 class RearPortBulkEditForm(
@@ -1229,7 +1229,7 @@ class RearPortBulkEditForm(
     fieldsets = (
         (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
     )
-    nullable_fields = ('module', 'label', 'description')
+    nullable_fields = ('module', 'label', 'description', 'color')
 
 
 class ModuleBayBulkEditForm(

+ 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

+ 1 - 1
netbox/dcim/models/device_components.py

@@ -189,7 +189,7 @@ class PathEndpoint(models.Model):
     dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
     CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
 
-    `connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
+    `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
     """
     _path = models.ForeignKey(
         to='dcim.CablePath',

+ 2 - 0
netbox/dcim/models/racks.py

@@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
         max_length=200
     )
 
+    clone_fields = ('rack', 'user', 'tenant')
+
     class Meta:
         ordering = ['created', 'pk']
 

+ 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, raw, **kwargs):
+    """
+    When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
+    """
+    if created and not raw:
+        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()

+ 4 - 3
netbox/dcim/views.py

@@ -589,17 +589,18 @@ class RackElevationListView(generic.ObjectListView):
         racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
         total_count = racks.count()
 
+        # Ordering
         ORDERING_CHOICES = {
             'name': 'Name (A-Z)',
             '-name': 'Name (Z-A)',
             'facility_id': 'Facility ID (A-Z)',
             '-facility_id': 'Facility ID (Z-A)',
         }
-        sort = request.GET.get('sort', "name")
+        sort = request.GET.get('sort', 'name')
         if sort not in ORDERING_CHOICES:
             sort = 'name'
-
-        racks = racks.order_by(sort)
+        sort_field = sort.replace("name", "_name")  # Use natural ordering
+        racks = racks.order_by(sort_field)
 
         # Pagination
         per_page = get_paginate_count(request)

+ 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

@@ -803,6 +803,57 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
         self.assertEqual(site2.custom_field_data['multiobject_field'], 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/models.py

@@ -429,7 +429,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
 

+ 1 - 16
netbox/ipam/models/ip.py

@@ -8,8 +8,6 @@ from django.urls import reverse
 from django.utils.functional import cached_property
 
 from dcim.fields import ASNField
-from dcim.models import Device
-from netbox.models import OrganizationalModel, NetBoxModel
 from ipam.choices import *
 from ipam.constants import *
 from ipam.fields import IPNetworkField, IPAddressField
@@ -17,8 +15,7 @@ from ipam.managers import IPAddressManager
 from ipam.querysets import PrefixQuerySet
 from ipam.validators import DNSValidator
 from netbox.config import get_config
-from virtualization.models import VirtualMachine
-
+from netbox.models import OrganizationalModel, NetBoxModel
 
 __all__ = (
     'Aggregate',
@@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
                         )
                     })
 
-        # 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 = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])

+ 5 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.3.8'
+VERSION = '3.3.9'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -445,6 +445,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

@@ -125,9 +125,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):

+ 10 - 5
netbox/templates/dcim/device.html

@@ -229,7 +229,7 @@
                                 <th>Utilization</th>
                             </tr>
                             {% for powerport in object.powerports.all %}
-                                {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
+                                {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
                                     <tr>
                                         <td>{{ powerport }}</td>
                                         <td>{{ utilization.outlet_count }}</td>
@@ -247,10 +247,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 %}

+ 1 - 1
netbox/templates/dcim/interface.html

@@ -210,7 +210,7 @@
         <div class="card">
           <h5 class="card-header">Wireless</h5>
           <div class="card-body">
-            {% with peer=object.connected_endpoint %}
+            {% with peer=object.connected_endpoints.0 %}
               <table class="table table-hover">
                 <thead>
                   <tr>

+ 1 - 1
netbox/templates/dcim/rack.html

@@ -173,7 +173,7 @@
                                 <td>{{ powerfeed|linkify }}</td>
                                 <td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td>
                                 <td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td>
-                                {% with power_port=powerfeed.connected_endpoint %}
+                                {% with power_port=powerfeed.connected_endpoints.0 %}
                                     {% if power_port %}
                                         <td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td>
                                     {% else %}

+ 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

@@ -106,7 +106,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):

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

@@ -0,0 +1,21 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.serializers.json import Deserializer, Serializer as Serializer_  # noqa
+from django.utils.encoding import is_protected_type
+
+# NOTE: Module must contain both Serializer and Deserializer
+
+
+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,
     }
 

+ 1 - 0
netbox/utilities/templatetags/helpers.py

@@ -215,6 +215,7 @@ def status_from_tag(tag: str = "info") -> str:
         'warning': 'warning',
         'success': 'success',
         'error': 'danger',
+        'danger': 'danger',
         'debug': 'info',
         'info': 'info',
     }

+ 4 - 7
requirements.txt

@@ -11,7 +11,7 @@ django-redis==5.2.0
 django-rich==1.4.0
 django-rq==2.6.0
 django-tables2==2.4.1
-django-taggit==3.0.0
+django-taggit==3.1.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
 drf-yasg[validation]==1.21.4
@@ -19,21 +19,18 @@ graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==8.5.10
+mkdocs-material==8.5.11
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.3.0
 psycopg2-binary==2.9.5
 PyYAML==6.0
-sentry-sdk==1.11.0
+sentry-sdk==1.11.1
 social-auth-app-django==5.0.0
 social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 tablib==3.2.1
-tzdata==2022.6
+tzdata==2022.7
 
 # Workaround for #7401
 jsonschema==3.2.0
-
-# Temporary fix for #10712
-swagger-spec-validator==2.7.6