Merge branch 'develop' into 17419-mptt-module-bay

This commit is contained in:
Arthur Hanson 2024-09-24 13:16:08 -07:00
commit 0dd2a65007
21 changed files with 111 additions and 48 deletions

View File

@ -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

View File

@ -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)'),
)
),

View File

@ -51,6 +51,7 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_80211AY,
InterfaceTypeChoices.TYPE_80211BE,
InterfaceTypeChoices.TYPE_802151,
InterfaceTypeChoices.TYPE_802154,
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
]

View File

@ -9,7 +9,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import VRF
from ipam.models import VRF, IPAddress
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import (
@ -1435,9 +1435,33 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Status'),
choices=VirtualDeviceContextStatusChoices,
)
primary_ip4 = CSVModelChoiceField(
label=_('Primary IPv4'),
queryset=IPAddress.objects.all(),
required=False,
to_field_name='address',
help_text=_('IPv4 address with mask, e.g. 1.2.3.4/24')
)
primary_ip6 = CSVModelChoiceField(
label=_('Primary IPv6'),
queryset=IPAddress.objects.all(),
required=False,
to_field_name='address',
help_text=_('IPv6 address with prefix length, e.g. 2001:db8::1/64')
)
class Meta:
fields = [
'name', 'device', 'status', 'tenant', 'identifier', 'comments',
'name', 'device', 'status', 'tenant', 'identifier', 'comments', 'primary_ip4', 'primary_ip6',
]
model = VirtualDeviceContext
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit primary_ip4/ip6 querysets by assigned device
params = {f"interface__device__{self.fields['device'].to_field_name}": data.get('device')}
self.fields['primary_ip4'].queryset = self.fields['primary_ip4'].queryset.filter(**params)
self.fields['primary_ip6'].queryset = self.fields['primary_ip6'].queryset.filter(**params)

View File

@ -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:

View File

@ -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.")
})

View File

@ -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({

View File

@ -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

View File

@ -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(

View File

@ -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"
}

View File

@ -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]}

View File

@ -3253,10 +3253,10 @@ class CableEditView(generic.ObjectEditView):
doesn't currently provide a hook for dynamic class resolution.
"""
a_terminations_type = CABLE_TERMINATION_TYPES.get(
request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type')
request.POST.get('a_terminations_type') or request.GET.get('a_terminations_type')
)
b_terminations_type = CABLE_TERMINATION_TYPES.get(
request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type')
request.POST.get('b_terminations_type') or request.GET.get('b_terminations_type')
)
if obj.pk:

View File

@ -29,6 +29,7 @@ class ASNIndex(SearchIndex):
class ASNRangeIndex(SearchIndex):
model = models.ASNRange
fields = (
('name', 100),
('description', 500),
)
display_attrs = ('rir', 'tenant', 'description')

View File

@ -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

View File

@ -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)
})

View File

@ -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):

View File

@ -50,7 +50,7 @@
</thead>
<tbody>
{% 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 %}
<tr>
<td>
{% if script.is_executable %}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-18 05:02+0000\n"
"POT-Creation-Date: 2024-09-20 05:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -4492,6 +4492,28 @@ msgstr ""
msgid "Single or three-phase"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1439 netbox/dcim/forms/model_forms.py:1669
#: netbox/templates/dcim/device.html:190
#: netbox/templates/dcim/virtualdevicecontext.html:30
#: netbox/templates/virtualization/virtualmachine.html:52
msgid "Primary IPv4"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1443
msgid "IPv4 address with mask, e.g. 1.2.3.4/24"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1446 netbox/dcim/forms/model_forms.py:1678
#: netbox/templates/dcim/device.html:206
#: netbox/templates/dcim/virtualdevicecontext.html:41
#: netbox/templates/virtualization/virtualmachine.html:68
msgid "Primary IPv6"
msgstr ""
#: netbox/dcim/forms/bulk_import.py:1450
msgid "IPv6 address with prefix length, e.g. 2001:db8::1/64"
msgstr ""
#: netbox/dcim/forms/common.py:24 netbox/dcim/models/device_components.py:528
#: netbox/templates/dcim/interface.html:57
#: netbox/templates/virtualization/vminterface.html:55
@ -4898,18 +4920,6 @@ msgstr ""
msgid "Inventory Item Role"
msgstr ""
#: netbox/dcim/forms/model_forms.py:1669 netbox/templates/dcim/device.html:190
#: netbox/templates/dcim/virtualdevicecontext.html:30
#: netbox/templates/virtualization/virtualmachine.html:52
msgid "Primary IPv4"
msgstr ""
#: netbox/dcim/forms/model_forms.py:1678 netbox/templates/dcim/device.html:206
#: netbox/templates/dcim/virtualdevicecontext.html:41
#: netbox/templates/virtualization/virtualmachine.html:68
msgid "Primary IPv6"
msgstr ""
#: netbox/dcim/forms/object_create.py:48 netbox/dcim/forms/object_create.py:199
#: netbox/dcim/forms/object_create.py:355
msgid ""
@ -12187,7 +12197,7 @@ msgid "Parent Bay"
msgstr ""
#: netbox/templates/dcim/device_edit.html:48
#: netbox/utilities/templates/form_helpers/render_field.html:20
#: netbox/utilities/templates/form_helpers/render_field.html:22
msgid "Regenerate Slug"
msgstr ""
@ -14695,15 +14705,15 @@ msgstr ""
msgid "Subscribe"
msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:39
#: netbox/utilities/templates/form_helpers/render_field.html:41
msgid "Copy to clipboard"
msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:55
#: netbox/utilities/templates/form_helpers/render_field.html:57
msgid "This field is required"
msgstr ""
#: netbox/utilities/templates/form_helpers/render_field.html:68
#: netbox/utilities/templates/form_helpers/render_field.html:70
msgid "Set Null"
msgstr ""

View File

@ -6,9 +6,11 @@
{# Render the field label (if any), except for checkboxes #}
{% if label and not field|widget_type == 'checkboxinput' %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }}
</label>
<div class="col-sm-3 text-lg-end">
<label for="{{ field.id_for_label }}" class="col-form-label d-inline-block{% if field.field.required %} required{% endif %}">
{{ label }}
</label>
</div>
{% endif %}
{# Render the field itself #}

View File

@ -134,7 +134,7 @@ class Cluster(ContactsMixin, PrimaryModel):
super().clean()
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
if self.pk and self.site:
if not self._state.adding and self.site:
if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
raise ValidationError({
'site': _(

View File

@ -205,7 +205,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
})
# Validate aggregate disk size
if self.pk:
if not self._state.adding:
total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
if total_disk and self.disk is None:
self.disk = total_disk