diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 3beec4cf7..03a58a2d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 6688de9fe..94c879aed 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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 diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 150c32f40..ab2f1f81b 100644 --- a/docs/customization/reports.md +++ b/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( diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4894690de..6de56fd31 100644 --- a/docs/release-notes/version-3.3.md +++ b/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 diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 396f7e59b..db2d3ca2a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index e05eb6d51..d00cfd4e6 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/models/device_components.py b/netbox/dcim/models/device_components.py index 8f1285901..ce768e439 100644 --- a/netbox/dcim/models/device_components.py +++ b/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', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 20027675a..df4702501 100644 --- a/netbox/dcim/models/racks.py +++ b/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'] diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index b990daf1a..522bb76c0 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, 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() 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/dcim/views.py b/netbox/dcim/views.py index 5930d6b2d..e8a7c66bd 100644 --- a/netbox/dcim/views.py +++ b/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) 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 946999bc2..a023dd7fb 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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}) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 95723f80d..43fd12cd2 100644 --- a/netbox/ipam/forms/models.py +++ b/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 diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 456bab4f0..1859ef3e1 100644 --- a/netbox/ipam/models/ip.py +++ b/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({ diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index c50ad9ca6..d6504282e 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 = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 46663f08c..6ce4148a2 100644 --- a/netbox/netbox/settings.py +++ b/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 diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index c7545192a..81fdaa20f 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 3b0c77251..15869e04c 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/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): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0cd76de4..0080c8cf3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -229,7 +229,7 @@
{{ powerfeed|linkify }} | {% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %} | {% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %} | - {% with power_port=powerfeed.connected_endpoint %} + {% with power_port=powerfeed.connected_endpoints.0 %} {% if power_port %}{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %} | {% else %} diff --git a/netbox/users/apps.py b/netbox/users/apps.py new file mode 100644 index 000000000..b8d67f1c3 --- /dev/null +++ b/netbox/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' + + def ready(self): + import users.signals diff --git a/netbox/users/signals.py b/netbox/users/signals.py new file mode 100644 index 000000000..8915af1dc --- /dev/null +++ b/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}") diff --git a/netbox/users/views.py b/netbox/users/views.py index 33ef3fadd..c688d6b4f 100644 --- a/netbox/users/views.py +++ b/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, diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 258399e86..d87613b20 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/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): diff --git a/netbox/utilities/serializers/json.py b/netbox/utilities/serializers/json.py new file mode 100644 index 000000000..b728b0a34 --- /dev/null +++ b/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) diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 9f3779bfe..4c6c46ef0 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -8,7 +8,7 @@