diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index 5eccef588..d0c303336 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -1,5 +1,25 @@ # NetBox v4.1 +## v4.1.2 (FUTURE) + +### Enhancements + +* [#14201](https://github.com/netbox-community/netbox/issues/14201) - Enable global search for AS numbers using "AS" prefix +* [#15408](https://github.com/netbox-community/netbox/issues/15408) - Enable bulk import of primary IPv4 & IPv6 addresses for virtual device contexts (VDCs) +* [#17471](https://github.com/netbox-community/netbox/issues/17471) - Add Eaton C39 power outlet type +* [#17482](https://github.com/netbox-community/netbox/issues/17482) - Do not preload Branch & StagedChange models in `nbshell` + +### Bug Fixes + +* [#17083](https://github.com/netbox-community/netbox/issues/17083) - Trim clickable area of form field labels +* [#17406](https://github.com/netbox-community/netbox/issues/17406) - Fix the cleanup of stale custom field data after removing a plugin +* [#17492](https://github.com/netbox-community/netbox/issues/17492) - Fix URL resolution in `NetBoxModelSerializer` for plugin models +* [#17517](https://github.com/netbox-community/netbox/issues/17517) - Fix cable termination selection after switching termination type +* [#17521](https://github.com/netbox-community/netbox/issues/17521) - Correct text color in notification pop-ups under dark mode +* [#17522](https://github.com/netbox-community/netbox/issues/17522) - Fix language translation of form field labels under user preferences + +--- + ## v4.1.1 (2024-09-12) ### Enhancements diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c90a0b3ea..848f57d7e 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -924,6 +924,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AY = 'ieee802.11ay' TYPE_80211BE = 'ieee802.11be' TYPE_802151 = 'ieee802.15.1' + TYPE_802154 = 'ieee802.15.4' TYPE_OTHER_WIRELESS = 'other-wireless' # Cellular @@ -1096,6 +1097,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AY, 'IEEE 802.11ay'), (TYPE_80211BE, 'IEEE 802.11be'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), + (TYPE_802154, 'IEEE 802.15.4 (LR-WPAN)'), (TYPE_OTHER_WIRELESS, 'Other (Wireless)'), ) ), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 049be6117..ba3e6464b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -51,6 +51,7 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211AY, InterfaceTypeChoices.TYPE_80211BE, InterfaceTypeChoices.TYPE_802151, + InterfaceTypeChoices.TYPE_802154, InterfaceTypeChoices.TYPE_OTHER_WIRELESS, ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 2fac55dd4..9da599109 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -164,7 +164,7 @@ class Cable(PrimaryModel): if self.length is not None and not self.length_unit: raise ValidationError(_("Must specify a unit when setting a cable length")) - if self.pk is None and (not self.a_terminations or not self.b_terminations): + if self._state.adding and (not self.a_terminations or not self.b_terminations): raise ValidationError(_("Must define A and B terminations when creating a new cable.")) if self._terminations_modified: diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 28a403be0..5f6aa08e3 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -98,7 +98,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): def clean(self): super().clean() - if self.pk is not None and self._original_device_type != self.device_type_id: + if not self._state.adding and self._original_device_type != self.device_type_id: raise ValidationError({ "device_type": _("Component templates cannot be moved to a different device type.") }) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 62312cbf4..f5fbaa956 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -561,7 +561,7 @@ class BaseInterface(models.Model): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: + if not self._state.adding and self.mode != InterfaceModeChoices.MODE_TAGGED: self.tagged_vlans.clear() return super().save(*args, **kwargs) @@ -1072,7 +1072,7 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts - if self.pk: + if not self._state.adding: frontport_count = self.frontports.count() if self.positions < frontport_count: raise ValidationError({ @@ -1314,7 +1314,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): }) # Validation for moving InventoryItems - if self.pk: + if not self._state.adding: # Cannot move an InventoryItem to another device if it has a parent if self.parent and self.parent.device != self.device: raise ValidationError({ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index c281e5de2..a2b78957e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -293,7 +293,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. - if self.pk and self.u_height > self._original_u_height: + if not self._state.adding and self.u_height > self._original_u_height: for d in Device.objects.filter(device_type=self, position__isnull=False): face_required = None if self.is_full_depth else d.face u_available = d.rack.get_available_units( @@ -310,7 +310,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): }) # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position. - elif self.pk and self._original_u_height > 0 and self.u_height == 0: + elif not self._state.adding and self._original_u_height > 0 and self.u_height == 0: racked_instance_count = Device.objects.filter( device_type=self, position__isnull=False @@ -1351,7 +1351,7 @@ class VirtualChassis(PrimaryModel): # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new # VirtualChassis.) - if self.pk and self.master and self.master not in self.members.all(): + if not self._state.adding and self.master and self.master not in self.members.all(): raise ValidationError({ 'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format( master=self.master diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3198c2679..3aead09ca 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -382,7 +382,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): if self.max_weight and not self.weight_unit: raise ValidationError(_("Must specify a unit when setting a maximum weight")) - if self.pk: + if not self._state.adding: mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') # Validate that Rack is tall enough to house the highest mounted Device @@ -468,7 +468,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): } # Add devices to rack units list - if self.pk: + if not self._state.adding: # Retrieve all devices installed within the rack devices = Device.objects.prefetch_related( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 222d756a1..2fa82dc43 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -684,7 +684,8 @@ class DeviceInterfaceTable(InterfaceTable): 'data-virtual': lambda record: "true" if record.is_virtual else "false", 'data-mark-connected': lambda record: "true" if record.mark_connected else "false", 'data-cable-status': lambda record: record.cable.status if record.cable else "", - 'data-type': lambda record: record.type + 'data-type': lambda record: record.type, + 'data-connected': lambda record: "connected" if record.mark_connected or record.cable else "disconnected" } diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index e2d52a609..afb360d76 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5247,6 +5247,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): def test_type(self): params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'type__empty': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'type__empty': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_status(self): params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 414321b7d..16a8eba3c 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -29,6 +29,7 @@ class ASNIndex(SearchIndex): class ASNRangeIndex(SearchIndex): model = models.ASNRange fields = ( + ('name', 100), ('description', 500), ) display_attrs = ('rir', 'tenant', 'description') diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ac43fe57f..e3bd33298 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -133,7 +133,7 @@ class BaseFilterSet(django_filters.FilterSet): django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter, TagFilter - )) or existing_filter.extra.get('choices'): + )): # These filter types support only negation return FILTER_NEGATION_LOOKUP_MAP @@ -172,6 +172,7 @@ class BaseFilterSet(django_filters.FilterSet): # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): new_filter_name = f'{existing_filter_name}__{lookup_name}' + existing_filter_extra = deepcopy(existing_filter.extra) try: if existing_filter_name in cls.declared_filters: @@ -179,6 +180,8 @@ class BaseFilterSet(django_filters.FilterSet): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + for field_to_remove in ('choices', 'null_value'): + existing_filter_extra.pop(field_to_remove, None) filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) new_filter = filter_cls( field_name=field_name, @@ -186,7 +189,7 @@ class BaseFilterSet(django_filters.FilterSet): label=existing_filter.label, exclude=existing_filter.exclude, distinct=existing_filter.distinct, - **existing_filter.extra + **existing_filter_extra ) elif hasattr(existing_filter, 'custom_field'): # Filter is for a custom field diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 4ba5f60da..aea5f00cc 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -162,7 +162,7 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): super().clean() # An MPTT model cannot be its own parent - if self.pk and self.parent and self.parent in self.get_descendants(include_self=True): + if not self._state.adding and self.parent and self.parent in self.get_descendants(include_self=True): raise ValidationError({ "parent": "Cannot assign self or child {type} as parent.".format(type=self._meta.verbose_name) }) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 45eb70081..a97227770 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -408,14 +408,9 @@ class JobsMixin(models.Model): def get_latest_jobs(self): """ - Return a dictionary mapping of the most recent jobs for this instance. + Return a list of the most recent jobs for this instance. """ - return { - job.name: job - for job in self.jobs.filter( - status__in=JobStatusChoices.TERMINAL_STATE_CHOICES - ).order_by('name', '-created').distinct('name').defer('data') - } + return self.jobs.filter(status__in=JobStatusChoices.TERMINAL_STATE_CHOICES).order_by('-created').defer('data') class JournalingMixin(models.Model): diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 4699e2c09..5b7361a12 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -50,7 +50,7 @@
{% for script in module.scripts.all %} - {% with last_job=script.get_latest_jobs|get_key:script.name %} + {% with last_job=script.get_latest_jobs|first %}