mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-03 14:18:16 -06:00
Merge branch 'develop' into 17419-mptt-module-bay
This commit is contained in:
commit
0dd2a65007
@ -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
|
||||
|
@ -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)'),
|
||||
)
|
||||
),
|
||||
|
@ -51,6 +51,7 @@ WIRELESS_IFACE_TYPES = [
|
||||
InterfaceTypeChoices.TYPE_80211AY,
|
||||
InterfaceTypeChoices.TYPE_80211BE,
|
||||
InterfaceTypeChoices.TYPE_802151,
|
||||
InterfaceTypeChoices.TYPE_802154,
|
||||
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
||||
]
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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.")
|
||||
})
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
@ -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]}
|
||||
|
@ -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:
|
||||
|
@ -29,6 +29,7 @@ class ASNIndex(SearchIndex):
|
||||
class ASNRangeIndex(SearchIndex):
|
||||
model = models.ASNRange
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('rir', 'tenant', 'description')
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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):
|
||||
|
@ -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 %}
|
||||
|
@ -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 ""
|
||||
|
||||
|
@ -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 %}">
|
||||
<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 #}
|
||||
|
@ -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': _(
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user