Skip to content

Commit

Permalink
Merge pull request #11059 from netbox-community/develop
Browse files Browse the repository at this point in the history
Release v3.3.9
  • Loading branch information
jeremystretch authored Nov 30, 2022
2 parents bfda5d9 + f2f36c6 commit 85c6067
Show file tree
Hide file tree
Showing 32 changed files with 224 additions and 65 deletions.
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/customization/reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions docs/release-notes/version-3.3.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions netbox/dcim/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions netbox/dcim/models/cables.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,17 @@ def __str__(self):
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")
Expand Down Expand Up @@ -570,6 +581,7 @@ def from_origin(cls, terminations):
[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
Expand Down
2 changes: 1 addition & 1 deletion netbox/dcim/models/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions netbox/dcim/models/racks.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
max_length=200
)

clone_fields = ('rack', 'user', 'tenant')

class Meta:
ordering = ['created', 'pk']

Expand Down
15 changes: 14 additions & 1 deletion netbox/dcim/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
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

Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions netbox/dcim/tests/test_cablepaths.py
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@ def test_214_interface_to_providernetwork_via_circuit(self):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)

# Delete cable 1
cable1.delete()
Expand Down
7 changes: 4 additions & 3 deletions netbox/dcim/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,17 +589,18 @@ def get(self, request):
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)
Expand Down
18 changes: 18 additions & 0 deletions netbox/extras/api/customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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


#
Expand Down Expand Up @@ -69,6 +70,23 @@ def to_internal_value(self, data):
"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}
Expand Down
6 changes: 2 additions & 4 deletions netbox/extras/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook


#
# Change logging/webhooks
#
Expand Down Expand Up @@ -100,16 +99,15 @@ 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:
return

# 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
Expand Down
51 changes: 51 additions & 0 deletions netbox/extras/tests/test_customfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,57 @@ def test_update_single_object_with_values(self):
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})
Expand Down
3 changes: 2 additions & 1 deletion netbox/ipam/forms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,8 @@ def __init__(self, *args, **kwargs):
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

Expand Down
17 changes: 1 addition & 16 deletions netbox/ipam/models/ip.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@
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
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',
Expand Down Expand Up @@ -912,18 +909,6 @@ def clean(self):
)
})

# 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({
Expand Down
4 changes: 1 addition & 3 deletions netbox/netbox/api/viewsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,7 @@ def dispatch(self, request, *args, **kwargs):
)

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'])
Expand Down
6 changes: 5 additions & 1 deletion netbox/netbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
# Environment setup
#

VERSION = '3.3.8'
VERSION = '3.3.9'

# Hostname
HOSTNAME = platform.node()
Expand Down Expand Up @@ -445,6 +445,10 @@ def _setting(name, default=None):
f'/{BASE_PATH}metrics',
)

SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}


#
# Sentry
Expand Down
6 changes: 6 additions & 0 deletions netbox/netbox/tables/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ def __init__(self, customfield, *args, **kwargs):
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)

Expand Down
Loading

0 comments on commit 85c6067

Please sign in to comment.