diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a46424df0..ffb3b9ffe 100644 --- a/docs/release-notes/version-3.3.md +++ b/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) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c51b59f94..fc9d2d7a1 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/signals.py b/netbox/dcim/signals.py index b990daf1a..f223f9c5a 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, **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() 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/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 6ca354438..c88e6021d 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/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}) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3581ba104..430a4b2f8 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/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 diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 20530d66c..e8bf13375 100644 --- a/netbox/ipam/models/ip.py +++ b/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({ diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index e5e842696..d7e226c04 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 = ExportTemplate.objects.filter(content_types=content_type, name=request.GET['export']).first() diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 057816554..4dbf48c36 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -440,6 +440,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 5e92196e5..492a64abf 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 738d70786..795f4ad56 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/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): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cb7a7133d..083417cd3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -283,10 +283,15 @@ Leg {{ leg.name }} {{ leg.outlet_count }} {{ leg.allocated }} - {{ powerfeed.available_power|divide:3 }}VA - {% with phase_available=powerfeed.available_power|divide:3 %} - {% utilization_graph leg.allocated|percentage:phase_available %} - {% endwith %} + {% if powerfeed.available_power %} + {% with phase_available=powerfeed.available_power|divide:3 %} + {{ phase_available }}VA + {% utilization_graph leg.allocated|percentage:phase_available %} + {% endwith %} + {% else %} + — + — + {% endif %} {% endfor %} {% endwith %} 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 1ceeb8211..130efe3b2 100644 --- a/netbox/users/views.py +++ b/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, 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..d2e682678 --- /dev/null +++ b/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) 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 @@
{{ field }}
{% if field.help_text %} @@ -23,7 +23,7 @@ -{% elif field|widget_type == 'textarea' and not field.label %} +{% elif field|widget_type == 'textarea' and not label %}
{% if label %}