Merge pull request #5581 from netbox-community/develop

Release v2.10.3
This commit is contained in:
Jeremy Stretch 2021-01-05 21:09:50 -05:00 committed by GitHub
commit 5a32b9599a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 304 additions and 132 deletions

View File

@ -27,13 +27,13 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ
CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts
```
The NetBox project utilizes three long-term branches:
The NetBox project utilizes three persistent git branches to track work:
* `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable release occurs here
* `develop-x.y` - Tracks work on an upcoming major release
* `feature` - Tracks work on an upcoming major release
Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch.
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
### Enable Pre-Commit Hooks

View File

@ -52,10 +52,7 @@ Close the release milestone on GitHub after ensuring there are no remaining open
### Merge the Release Branch
Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases.
!!! warning
No further releases for the current major version can be published once this pull request is merged.
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
---

View File

@ -83,7 +83,7 @@ Checking connectivity... done.
```
!!! note
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release.
## Create the NetBox System User

View File

@ -1,5 +1,28 @@
# NetBox v2.10
## v2.10.3 (2021-01-05)
### Bug Fixes
* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors
* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters
* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site
* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar
* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields
* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned
* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices
* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis
* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis
* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list
* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API
* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields
* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link
* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw`
* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field
* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data
---
## v2.10.2 (2020-12-21)
### Enhancements

View File

@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions
- `nisw` - negated case insensitive starts with
- `iew` - case insensitive ends with
- `niew` - negated case insensitive ends with
- `ie` - case sensitive exact match
- `nie` - negated case sensitive exact match
- `ie` - case insensitive exact match
- `nie` - negated case insensitive exact match
### Foreign Keys & Other Fields

View File

@ -65,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet
brief_prefetch_fields = ['circuit']

View File

@ -258,6 +258,7 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
brief_prefetch_fields = ['manufacturer']
#
@ -493,6 +494,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilterSet
brief_prefetch_fields = ['device']
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
@ -501,18 +503,21 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilterSet
brief_prefetch_fields = ['device']
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilterSet
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilterSet
brief_prefetch_fields = ['device']
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
@ -521,30 +526,35 @@ class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilterSet
brief_prefetch_fields = ['device']
class FrontPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet
brief_prefetch_fields = ['device']
class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet
brief_prefetch_fields = ['device']
class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer
filterset_class = filters.DeviceBayFilterSet
brief_prefetch_fields = ['device']
class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filters.InventoryItemFilterSet
brief_prefetch_fields = ['device']
#
@ -600,6 +610,7 @@ class VirtualChassisViewSet(ModelViewSet):
)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
brief_prefetch_fields = ['master']
#

View File

@ -134,6 +134,7 @@ class ComponentForm(BootstrapMixin, forms.Form):
)
def clean(self):
super().clean()
# Validate that the number of components being created from both the name_pattern and label_pattern are equal
if self.cleaned_data['label_pattern']:
@ -783,7 +784,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
]
class RackReservationCSVForm(CSVModelForm):
class RackReservationCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@ -833,7 +834,7 @@ class RackReservationCSVForm(CSVModelForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@ -1438,6 +1439,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
@ -1781,9 +1783,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'group_id': '$rack_group',
}
)
position = forms.TypedChoiceField(
position = forms.IntegerField(
required=False,
empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
@ -1856,6 +1857,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
"config context",
}
widgets = {
'face': StaticSelect2(),
'status': StaticSelect2(),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@ -1902,6 +1904,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
)
# Disable rack assignment if this is a child device installed in a parent device
if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
else:
# An object that doesn't exist yet can't have any IPs assigned to it
@ -1911,31 +1920,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Rack position
pk = self.instance.pk if self.instance.pk else None
try:
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
position_choices = Rack.objects.get(pk=self.data['rack']) \
.get_rack_units(face=self.data.get('face'), exclude=pk)
elif self.initial.get('rack') and str(self.initial.get('face')):
position_choices = Rack.objects.get(pk=self.initial['rack']) \
.get_rack_units(face=self.initial.get('face'), exclude=pk)
else:
position_choices = []
except Rack.DoesNotExist:
position_choices = []
self.fields['position'].choices = [('', '---------')] + [
(p['id'], {
'label': p['name'],
'disabled': bool(p['device'] and p['id'] != self.initial.get('position')),
}) for p in position_choices
]
# Disable rack assignment if this is a child device installed in a parent device
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
self.fields['rack'].disabled = True
self.initial['site'] = self.instance.parent_bay.device.site_id
self.initial['rack'] = self.instance.parent_bay.device.rack_id
position = self.data.get('position') or self.initial.get('position')
if position:
self.fields['position'].widget.choices = [(position, f'U{position}')]
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
@ -2944,6 +2931,7 @@ class InterfaceBulkEditForm(
self.fields['lag'].widget.attrs['disabled'] = True
def clean(self):
super().clean()
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
@ -3092,6 +3080,7 @@ class FrontPortCreateForm(ComponentCreateForm):
self.fields['rear_port_set'].choices = choices
def clean(self):
super().clean()
# Validate that the number of ports being created equals the number of selected (rear port, position) tuples
front_port_count = len(self.cleaned_data['name_pattern'])
@ -3786,7 +3775,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm):
}
class CableCSVForm(CSVModelForm):
class CableCSVForm(CustomFieldModelCSVForm):
# Termination A
side_a_device = CSVModelChoiceField(
queryset=Device.objects.all(),
@ -3881,7 +3870,7 @@ class CableCSVForm(CSVModelForm):
return length_unit if length_unit is not None else ''
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@ -3924,6 +3913,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
]
def clean(self):
super().clean()
# Validate length/unit
length = self.cleaned_data.get('length')
@ -4267,7 +4257,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
return device
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
widget=forms.MultipleHiddenInput()
@ -4281,7 +4271,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
nullable_fields = ['domain']
class VirtualChassisCSVForm(CSVModelForm):
class VirtualChassisCSVForm(CustomFieldModelCSVForm):
master = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
@ -4368,7 +4358,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
]
class PowerPanelCSVForm(CSVModelForm):
class PowerPanelCSVForm(CustomFieldModelCSVForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@ -4394,7 +4384,7 @@ class PowerPanelCSVForm(CSVModelForm):
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
@ -4422,9 +4412,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
)
class Meta:
nullable_fields = (
'rack_group',
)
nullable_fields = ['rack_group']
class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
allocated_draw=self.allocated_draw
)
def clean(self):
super().clean()
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
class PowerOutletTemplate(ComponentTemplateModel):
"""
@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def clean(self):
super().clean()
# Validate power port assignment
if self.power_port and self.power_port.device_type != self.device_type:
@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:

View File

@ -316,6 +316,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
)
def clean(self):
super().clean()
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
@ -425,6 +426,7 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
)
def clean(self):
super().clean()
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
@ -503,7 +505,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
verbose_name='Management only',
help_text='This interface is used only for out-of-band management'
)
untagged_vlan = models.ForeignKey(
@ -555,6 +557,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
)
def clean(self):
super().clean()
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
@ -668,6 +671,7 @@ class FrontPort(CableTermination, ComponentModel):
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device != self.device:
@ -711,6 +715,7 @@ class RearPort(CableTermination, ComponentModel):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Check that positions count is greater than or equal to the number of associated FrontPorts
frontport_count = self.frontports.count()
@ -768,6 +773,7 @@ class DeviceBay(ComponentModel):
)
def clean(self):
super().clean()
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:

View File

@ -640,7 +640,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
})
if self.rack is None:
@ -650,7 +650,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
'position': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
@ -662,7 +662,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Prevent 0U devices from being assigned to a specific position
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
})
if self.rack:
@ -688,8 +688,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
'position': f"U{self.position} is already occupied or does not have sufficient space to "
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
})
except DeviceType.DoesNotExist:

View File

@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
)
def clean(self):
super().clean()
# Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.site:
@ -326,22 +327,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
'group': "Rack group must be from the same site, {}.".format(self.site)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super().save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
devices = Device.objects.filter(rack=self)
for device in devices:
device.site = self.site
device.save()
def to_csv(self):
return (
self.site.name,

View File

@ -7,7 +7,7 @@ from django.db import transaction
from django.dispatch import receiver
from .choices import CableStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis
def create_cablepath(node):
@ -36,6 +36,43 @@ def rebuild_paths(obj):
create_cablepath(cp.origin)
#
# Site/rack/device assignment
#
@receiver(post_save, sender=RackGroup)
def handle_rackgroup_site_change(instance, created, **kwargs):
"""
Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child
object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
"""
if not created:
for rackgroup in instance.get_children():
rackgroup.site = instance.site
rackgroup.save()
for rack in Rack.objects.filter(group=instance).exclude(site=instance.site):
rack.site = instance.site
rack.save()
for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site):
powerpanel.site = instance.site
powerpanel.save()
@receiver(post_save, sender=Rack)
def handle_rack_site_change(instance, created, **kwargs):
"""
Update child Devices if Site assignment has changed.
"""
if not created:
for device in Device.objects.filter(rack=instance).exclude(site=instance.site):
device.site = instance.site
device.save()
#
# Virtual chassis
#
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
@ -60,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
device.save()
#
# Cables
#
@receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs):
"""

View File

@ -26,7 +26,8 @@ class RackGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site = tables.LinkColumn(
viewname='dcim:site',

View File

@ -19,7 +19,8 @@ class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
site_count = tables.Column(
verbose_name='Sites'

View File

@ -57,13 +57,10 @@ INTERFACE_TAGGED_VLANS = """
"""
MPTT_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
</span>
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
POWERFEED_CABLE = """

View File

@ -82,7 +82,7 @@ class DeviceTestCase(TestCase):
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_non_racked_device_with_face_position(self):
def test_non_racked_device_with_face(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
@ -92,12 +92,26 @@ class DeviceTestCase(TestCase):
'site': Site.objects.first().pk,
'rack': None,
'face': DeviceFaceChoices.FACE_REAR,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('face', form.errors)
def test_non_racked_device_with_position(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(form.is_valid())
self.assertIn('position', form.errors)

View File

@ -7,6 +7,42 @@ from dcim.models import *
from tenancy.models import Tenant
class RackGroupTestCase(TestCase):
def test_change_rackgroup_site(self):
"""
Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology:
Site A
- RackGroup A1
- RackGroup A2
- Rack 2
- Rack 1
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1')
rackgroup_a1.save()
rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2')
rackgroup_a2.save()
rack1 = Rack.objects.create(site=site_a, group=rackgroup_a1, name='Rack 1')
rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2')
powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1')
# Move RackGroup A1 to Site B
rackgroup_a1.site = site_b
rackgroup_a1.save()
# Check that all objects within RackGroup A1 now belong to Site B
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b)
self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b)
self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b)
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
class RackTestCase(TestCase):
def setUp(self):
@ -154,6 +190,34 @@ class RackTestCase(TestCase):
)
self.assertTrue(pdu)
def test_change_rack_site(self):
"""
Check that child Devices get updated when a Rack is moved to a new Site.
"""
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
# Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
# Move Rack1 to Site B
rack1.site = site_b
rack1.save()
# Check that Device1 is now assigned to Site B
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
class DeviceTestCase(TestCase):

View File

@ -39,7 +39,6 @@ class ConfigContextQuerySetMixin:
Provides a get_queryset() method which deals with adding the config context
data annotation or not.
"""
def get_queryset(self):
"""
Build the proper queryset based on the request context
@ -49,11 +48,11 @@ class ConfigContextQuerySetMixin:
Else, return the queryset annotated with config context data
"""
queryset = super().get_queryset()
request = self.get_serializer_context()['request']
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
return self.queryset
return self.queryset.annotate_config_context_data()
if self.brief or 'config_context' in request.query_params.get('exclude', []):
return queryset
return queryset.annotate_config_context_data()
#

View File

@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor):
cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
try:
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value)
except ValueError as e:
except Exception as e:
print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
raise e
model.objects.filter(pk=cfv.obj_id).update(**cf_data)

View File

@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
])
def clean(self):
super().clean()
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
# Validate all field values
@ -172,6 +174,8 @@ class CustomField(models.Model):
obj.save()
def clean(self):
super().clean()
# Validate the field's default value (if any)
if self.default is not None:
try:
@ -192,7 +196,8 @@ class CustomField(models.Model):
})
# Regex validation can be set only for text fields
if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT:
regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL)
if self.validation_regex and self.type not in regex_types:
raise ValidationError({
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})

View File

@ -117,11 +117,15 @@ class Webhook(models.Model):
return self.name
def clean(self):
super().clean()
# At least one action type must be selected
if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError(
"You must select at least one type: create, update, and/or delete."
)
# CA file path requires SSL verification enabled
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
@ -436,6 +440,7 @@ class ConfigContext(ChangeLoggedModel):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
return data
def clean(self):
super().clean()
# Verify that JSON data is provided as an object

View File

@ -89,6 +89,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
}
base_query = Q(
Q(platforms=OuterRef('platform')) | Q(platforms=None),
Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None),
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
Q(
@ -111,8 +113,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
elif self.model._meta.model_name == 'virtualmachine':
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
region_field = 'cluster__site__region'

View File

@ -25,8 +25,18 @@ from .nested_serializers import *
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True)
import_targets = SerializedPKRelatedField(
queryset=RouteTarget.objects.all(),
serializer=NestedRouteTargetSerializer,
required=False,
many=True
)
export_targets = SerializedPKRelatedField(
queryset=RouteTarget.objects.all(),
serializer=NestedRouteTargetSerializer,
required=False,
many=True
)
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)

View File

@ -270,7 +270,7 @@ class PrefixTable(BaseTable):
pk = ToggleColumn()
prefix = tables.TemplateColumn(
template_code=PREFIX_LINK,
attrs={'th': {'style': 'padding-left: 17px'}}
attrs={'td': {'class': 'text-nowrap'}}
)
status = ChoiceFieldColumn(
default=AVAILABLE_LABEL

View File

@ -804,7 +804,7 @@ class ServiceListView(generic.ObjectListView):
filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
action_buttons = ('export',)
action_buttons = ('import', 'export')
class ServiceView(generic.ObjectView):

View File

@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import ProtectedError
from django_rq.queues import get_connection
from rest_framework import mixins, status
from rest_framework import status
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet as ModelViewSet_
from rq.worker import Worker
from netbox.api import BulkOperationSerializer
@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
# Viewsets
#
class ModelViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
BulkUpdateModelMixin,
BulkDestroyModelMixin,
GenericViewSet):
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
"""
Accept either a single object or a list of objects to create.
Extend DRF's ModelViewSet to support bulk update and delete functions.
"""
brief = False
brief_prefetch_fields = []
def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True
@ -142,22 +138,34 @@ class ModelViewSet(mixins.CreateModelMixin,
def get_serializer_class(self):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
# exists
request = self.get_serializer_context()['request']
if request.query_params.get('brief'):
# If using 'brief' mode, find and return the nested serializer for this model, if one exists
if self.brief:
logger.debug("Request is for 'brief' format; initializing nested serializer")
try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
logger.debug(f"Using serializer {serializer}")
return serializer
except SerializerNotFound:
pass
logger.debug(f"Nested serializer for {self.queryset.model} not found!")
# Fall back to the hard-coded serializer class
logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
def get_queryset(self):
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
if self.brief:
return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return super().get_queryset()
def initialize_request(self, request, *args, **kwargs):
# Check if brief=True has been passed
if request.method == 'GET' and request.GET.get('brief'):
self.brief = True
return super().initialize_request(request, *args, **kwargs)
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.10.2'
VERSION = '2.10.3'
# Hostname
HOSTNAME = platform.node()

View File

@ -798,8 +798,8 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Update custom fields
for name in custom_fields:
if name in form.nullable_fields and name in nullified_fields:
obj.custom_field_data.pop(name, None)
else:
obj.custom_field_data[name] = None
elif form.cleaned_data.get(name) not in (None, ''):
obj.custom_field_data[name] = form.cleaned_data[name]
obj.full_clean()

View File

@ -14,21 +14,21 @@ body {
.wrapper {
min-height: 100%;
height: auto !important;
margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */
margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */
padding-bottom: 30px;
}
.navbar-brand {
padding: 12px 15px 8px;
}
.footer, .push {
height: 60px; /* .push must be the same height as .footer */
height: 48px; /* .push must be the same height as .footer */
}
.footer {
background-color: #f5f5f5;
border-top: 1px solid #d0d0d0;
}
footer p {
margin: 20px 0;
margin: 12px 0;
}
#navbar_search {
padding: 0 8px;

View File

@ -100,7 +100,7 @@
</table>
</div>
{% include 'inc/custom_fields_panel.html' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %}
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>

View File

@ -23,7 +23,7 @@
<tr id="{{ iface.name }}">
<td>{{ iface }}</td>
{% if iface.connected_endpoint.device %}
<td class="configured_device" data="{{ iface.connected_endpoint.device }}">
<td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
</td>
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
@ -61,6 +61,7 @@ $(document).ready(function() {
// Glean configured hostnames/interfaces from the DOM
var configured_device = row.children('td.configured_device').attr('data');
var configured_chassis = row.children('td.configured_device').attr('data-chassis');
var configured_interface = row.children('td.configured_interface').attr('data');
var configured_interface_short = null;
if (configured_interface) {
@ -81,9 +82,9 @@ $(document).ready(function() {
// Apply colors to rows
if (!configured_device && lldp_device) {
row.addClass('info');
} else if (configured_device == lldp_device && configured_interface == lldp_interface) {
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface == lldp_interface) {
row.addClass('success');
} else if (configured_device == lldp_device && configured_interface_short == lldp_interface) {
} else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface_short == lldp_interface) {
row.addClass('success');
} else {
row.addClass('danger');

View File

@ -165,7 +165,7 @@
<td>Cable</td>
<td>
<a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
<a href="{% url 'dcim:powerfeed_trace' pk=object.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td>

View File

@ -7,9 +7,9 @@
<div class="panel-body">
{% render_field form.region %}
{% render_field form.site %}
{% render_field form.group %}
{% render_field form.name %}
{% render_field form.facility_id %}
{% render_field form.group %}
{% render_field form.status %}
{% render_field form.role %}
{% render_field form.serial %}

View File

@ -4,13 +4,10 @@ from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagCol
from .models import Tenant, TenantGroup
MPTT_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="mdi mdi-chevron-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
</span>
{% for i in record.get_ancestors %}
<i class="mdi mdi-circle-small"></i>
{% endfor %}
<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
"""
COL_TENANT = """
@ -30,7 +27,8 @@ class TenantGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
template_code=MPTT_LINK,
orderable=False
orderable=False,
attrs={'td': {'class': 'text-nowrap'}}
)
tenant_count = LinkedCountColumn(
viewname='tenancy:tenant_list',

View File

@ -169,6 +169,8 @@ class ObjectPermissionForm(forms.ModelForm):
self.instance.actions.remove(action)
def clean(self):
super().clean()
object_types = self.cleaned_data.get('object_types')
constraints = self.cleaned_data.get('constraints')

View File

@ -82,6 +82,7 @@ class BulkRenameForm(forms.Form):
)
def clean(self):
super().clean()
# Validate regular expression in "find" field
if self.cleaned_data['use_regex']:
@ -124,6 +125,7 @@ class ImportForm(BootstrapMixin, forms.Form):
)
def clean(self):
super().clean()
data = self.cleaned_data['data']
format = self.cleaned_data['format']

View File

@ -84,3 +84,4 @@ class VMInterfaceViewSet(ModelViewSet):
)
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine']

View File

@ -444,6 +444,7 @@ class VMInterface(BaseInterface):
)
def clean(self):
super().clean()
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: