diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 8745825ab2f..9b224965369 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -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 diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 22fa6f1e8d0..f3338ffd31d 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -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. --- diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 39189bbd1c6..9745df1f3ec 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -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 diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 27965090bbd..a3ab5968cfb 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -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 diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index 1ec6752a44b..b77513297fc 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -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 diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 6968da61e6c..736871a733e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -65,3 +65,4 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet + brief_prefetch_fields = ['circuit'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f9e8027b45f..ae39f6ad02d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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'] # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7ecd4efd804..f7eb510ecbe 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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 Meta: ] -class RackReservationCSVForm(CSVModelForm): +class RackReservationCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -833,7 +834,7 @@ def __init__(self, data=None, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 Meta: "config context", } widgets = { + 'face': StaticSelect2(), 'status': StaticSelect2(), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), @@ -1902,6 +1904,13 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 Meta: } -class CableCSVForm(CSVModelForm): +class CableCSVForm(CustomFieldModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3881,7 +3870,7 @@ def clean_length_unit(self): 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 Meta: ] def clean(self): + super().clean() # Validate length/unit length = self.cleaned_data.get('length') @@ -4267,7 +4257,7 @@ def clean_device(self): 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 Meta: nullable_fields = ['domain'] -class VirtualChassisCSVForm(CSVModelForm): +class VirtualChassisCSVForm(CustomFieldModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -4368,7 +4358,7 @@ class Meta: ] -class PowerPanelCSVForm(CSVModelForm): +class PowerPanelCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -4394,7 +4384,7 @@ def __init__(self, data=None, *args, **kwargs): 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): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 7a94b3e1b1f..58233f3bffe 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -164,6 +164,15 @@ def instantiate(self, device): 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 Meta: 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 Meta: ) def clean(self): + super().clean() # Validate rear port assignment if self.rear_port.device_type != self.device_type: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5d5825f5fa8..452aacb5679 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -316,6 +316,7 @@ def to_csv(self): ) 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 @@ def to_csv(self): ) 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 @@ def to_csv(self): ) def clean(self): + super().clean() # Virtual interfaces cannot be connected if self.type in NONCONNECTABLE_IFACE_TYPES and ( @@ -668,6 +671,7 @@ def to_csv(self): ) def clean(self): + super().clean() # Validate rear port assignment if self.rear_port.device != self.device: @@ -711,6 +715,7 @@ def get_absolute_url(self): 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 @@ def to_csv(self): ) def clean(self): + super().clean() # Validate that the parent Device can have DeviceBays if not self.device.device_type.is_parent_device: diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 5b685057eb0..29818ab9848 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -640,7 +640,7 @@ def clean(self): # 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 @@ def clean(self): }) 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 @@ def clean(self): # 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 @@ def clean(self): ) 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: diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index cad20241b0c..ccc775954e7 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -109,6 +109,7 @@ def to_objectchange(self, action): ) 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 @@ def clean(self): '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, diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 33c4b461c50..277e3f060cf 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -7,7 +7,7 @@ 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): """ diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 85943dd0d01..775e9007626 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -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', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 76f30f507c2..50a5e5ec788 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -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' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4a5aedb29af..ee1dc091be3 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -57,13 +57,10 @@ """ MPTT_LINK = """ -{% if record.get_children %} - -{% else %} - -{% endif %} - {{ record.name }} - +{% for i in record.get_ancestors %} + +{% endfor %} +{{ record.name }} """ POWERFEED_CABLE = """ diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index e8cb73fe417..6eeffbc965c 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -82,7 +82,7 @@ def test_non_racked_device(self): 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 @@ def test_non_racked_device_with_face_position(self): '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) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index b20e2110269..184681e903d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,6 +7,42 @@ 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 @@ def test_mount_zero_ru(self): ) 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): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 8ab7b0eeae6..1067ac0d399 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 @@ def get_queryset(self): 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() # diff --git a/netbox/extras/migrations/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py index 2c992c700dd..41b2febe7fc 100644 --- a/netbox/extras/migrations/0051_migrate_customfields.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -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) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 6f4c5f9e1f5..a69816d21de 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -47,6 +47,8 @@ def get_custom_fields(self): ]) 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 @@ def remove_stale_data(self, content_types): 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 @@ def clean(self): }) # 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" }) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 8934d97322c..4917a7e44ef 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -117,11 +117,15 @@ def __str__(self): 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 @@ def get_absolute_url(self): 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 @@ def get_config_context(self): return data def clean(self): - super().clean() # Verify that JSON data is provided as an object diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 8276787db5f..fbe7fe903d8 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -89,6 +89,8 @@ def _get_config_context_filters(self): } 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 @@ def _get_config_context_filters(self): 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' diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 7552ae0d21d..9b8d36590fa 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -25,8 +25,18 @@ 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) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index bea8ec25523..86d7e21ebf7 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -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 diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 36c22504560..39a840f7ff6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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): diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 881effd711d..991a8892dc8 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -9,11 +9,11 @@ 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 @@ def perform_bulk_destroy(self, objects): # 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 @@ def get_serializer(self, *args, **kwargs): 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) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3a6dc473fed..4c36332fcdf 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ # Environment setup # -VERSION = '2.10.2' +VERSION = '2.10.3' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index eb7b2542f3d..bd21d469c7a 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -798,8 +798,8 @@ def post(self, request, **kwargs): # 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() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 681565b9e69..94bd74a469d 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -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; diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index e96d5abcb53..8778c3ac2ba 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -100,7 +100,7 @@

{{ object }}

{% 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' %}
Comments diff --git a/netbox/templates/dcim/device/lldp_neighbors.html b/netbox/templates/dcim/device/lldp_neighbors.html index 3216b37918d..a2baa63220c 100644 --- a/netbox/templates/dcim/device/lldp_neighbors.html +++ b/netbox/templates/dcim/device/lldp_neighbors.html @@ -23,7 +23,7 @@ {{ iface }} {% if iface.connected_endpoint.device %} - + {{ iface.connected_endpoint.device }} @@ -61,6 +61,7 @@ // 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 @@ // 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'); diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index eded66fec39..be7e03ec5eb 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -165,7 +165,7 @@

{% block title %}{{ object }}{% endblock %}

Cable {{ object.cable }} - + diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 94456ac73d2..9ce242191e9 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -7,9 +7,9 @@
{% 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 %} diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 8abca2bb884..9e8be6b18d4 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -4,13 +4,10 @@ from .models import Tenant, TenantGroup MPTT_LINK = """ -{% if record.get_children %} - -{% else %} - -{% endif %} - {{ record.name }} - +{% for i in record.get_ancestors %} + +{% endfor %} +{{ record.name }} """ 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', diff --git a/netbox/users/admin.py b/netbox/users/admin.py index c03e6f74090..f2fe4a0b4b5 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -169,6 +169,8 @@ def __init__(self, *args, **kwargs): self.instance.actions.remove(action) def clean(self): + super().clean() + object_types = self.cleaned_data.get('object_types') constraints = self.cleaned_data.get('constraints') diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index f259f6b6d0b..e674afdf714 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -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'] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index ce5cb9f2c30..586ad502815 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -84,3 +84,4 @@ class VMInterfaceViewSet(ModelViewSet): ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filters.VMInterfaceFilterSet + brief_prefetch_fields = ['virtual_machine'] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index cf773734070..edca7e1fe1c 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -444,6 +444,7 @@ def to_csv(self): ) def clean(self): + super().clean() # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: