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 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 * `master` - Serves as a snapshot of the current stable release
* `develop` - All development on the upcoming stable release occurs here * `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 ### 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 ### 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. Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release.
!!! warning
No further releases for the current major version can be published once this pull request is merged.
--- ---

View File

@ -83,7 +83,7 @@ Checking connectivity... done.
``` ```
!!! note !!! 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 ## Create the NetBox System User

View File

@ -1,5 +1,28 @@
# NetBox v2.10 # 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) ## v2.10.2 (2020-12-21)
### Enhancements ### Enhancements

View File

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

View File

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

View File

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

View File

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

View File

@ -164,6 +164,15 @@ class PowerPortTemplate(ComponentTemplateModel):
allocated_draw=self.allocated_draw 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): class PowerOutletTemplate(ComponentTemplateModel):
""" """
@ -193,6 +202,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def clean(self): def clean(self):
super().clean()
# Validate power port assignment # Validate power port assignment
if self.power_port and self.power_port.device_type != self.device_type: if self.power_port and self.power_port.device_type != self.device_type:
@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel):
) )
def clean(self): def clean(self):
super().clean()
# Validate rear port assignment # Validate rear port assignment
if self.rear_port.device_type != self.device_type: if self.rear_port.device_type != self.device_type:

View File

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

View File

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

View File

@ -109,6 +109,7 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
) )
def clean(self): def clean(self):
super().clean()
# Parent RackGroup (if any) must belong to the same Site # Parent RackGroup (if any) must belong to the same Site
if self.parent and self.parent.site != self.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) '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): def to_csv(self):
return ( return (
self.site.name, self.site.name,

View File

@ -7,7 +7,7 @@ from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableStatusChoices 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): def create_cablepath(node):
@ -36,6 +36,43 @@ def rebuild_paths(obj):
create_cablepath(cp.origin) 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) @receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs): def assign_virtualchassis_master(instance, created, **kwargs):
""" """
@ -60,6 +97,11 @@ def clear_virtualchassis_members(instance, **kwargs):
device.save() device.save()
#
# Cables
#
@receiver(post_save, sender=Cable) @receiver(post_save, sender=Cable)
def update_connected_endpoints(instance, created, raw=False, **kwargs): def update_connected_endpoints(instance, created, raw=False, **kwargs):
""" """

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,42 @@ from dcim.models import *
from tenancy.models import Tenant 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): class RackTestCase(TestCase):
def setUp(self): def setUp(self):
@ -154,6 +190,34 @@ class RackTestCase(TestCase):
) )
self.assertTrue(pdu) 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): class DeviceTestCase(TestCase):

View File

@ -39,7 +39,6 @@ class ConfigContextQuerySetMixin:
Provides a get_queryset() method which deals with adding the config context Provides a get_queryset() method which deals with adding the config context
data annotation or not. data annotation or not.
""" """
def get_queryset(self): def get_queryset(self):
""" """
Build the proper queryset based on the request context Build the proper queryset based on the request context
@ -49,11 +48,11 @@ class ConfigContextQuerySetMixin:
Else, return the queryset annotated with config context data Else, return the queryset annotated with config context data
""" """
queryset = super().get_queryset()
request = self.get_serializer_context()['request'] request = self.get_serializer_context()['request']
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []): if self.brief or 'config_context' in request.query_params.get('exclude', []):
return self.queryset return queryset
return self.queryset.annotate_config_context_data() 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() cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first()
try: try:
cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value) 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})') print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})')
raise e raise e
model.objects.filter(pk=cfv.obj_id).update(**cf_data) model.objects.filter(pk=cfv.obj_id).update(**cf_data)

View File

@ -47,6 +47,8 @@ class CustomFieldModel(models.Model):
]) ])
def clean(self): def clean(self):
super().clean()
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
# Validate all field values # Validate all field values
@ -172,6 +174,8 @@ class CustomField(models.Model):
obj.save() obj.save()
def clean(self): def clean(self):
super().clean()
# Validate the field's default value (if any) # Validate the field's default value (if any)
if self.default is not None: if self.default is not None:
try: try:
@ -192,7 +196,8 @@ class CustomField(models.Model):
}) })
# Regex validation can be set only for text fields # 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({ raise ValidationError({
'validation_regex': "Regular expression validation is supported only for text and URL fields" '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 return self.name
def clean(self): 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: if not self.type_create and not self.type_delete and not self.type_update:
raise ValidationError( raise ValidationError(
"You must select at least one type: create, update, and/or delete." "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: if not self.ssl_verification and self.ca_file_path:
raise ValidationError({ raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.' '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}) return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self): def clean(self):
super().clean()
# Verify that JSON data is provided as an object # Verify that JSON data is provided as an object
if type(self.data) is not dict: if type(self.data) is not dict:
@ -482,7 +487,6 @@ class ConfigContextModel(models.Model):
return data return data
def clean(self): def clean(self):
super().clean() super().clean()
# Verify that JSON data is provided as an object # Verify that JSON data is provided as an object

View File

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

View File

@ -25,8 +25,18 @@ from .nested_serializers import *
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) import_targets = SerializedPKRelatedField(
export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) 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) ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)

View File

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

View File

@ -804,7 +804,7 @@ class ServiceListView(generic.ObjectListView):
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable table = tables.ServiceTable
action_buttons = ('export',) action_buttons = ('import', 'export')
class ServiceView(generic.ObjectView): 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 import transaction
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django_rq.queues import get_connection 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.response import Response
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.views import APIView 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 rq.worker import Worker
from netbox.api import BulkOperationSerializer from netbox.api import BulkOperationSerializer
@ -120,17 +120,13 @@ class BulkDestroyModelMixin:
# Viewsets # Viewsets
# #
class ModelViewSet(mixins.CreateModelMixin, class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
BulkUpdateModelMixin,
BulkDestroyModelMixin,
GenericViewSet):
""" """
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): def get_serializer(self, *args, **kwargs):
# If a list of objects has been provided, initialize the serializer with many=True # 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): def get_serializer_class(self):
logger = logging.getLogger('netbox.api.views.ModelViewSet') 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 # If using 'brief' mode, find and return the nested serializer for this model, if one exists
# exists if self.brief:
request = self.get_serializer_context()['request']
if request.query_params.get('brief'):
logger.debug("Request is for 'brief' format; initializing nested serializer") logger.debug("Request is for 'brief' format; initializing nested serializer")
try: try:
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
logger.debug(f"Using serializer {serializer}") logger.debug(f"Using serializer {serializer}")
return serializer return serializer
except SerializerNotFound: except SerializerNotFound:
pass logger.debug(f"Nested serializer for {self.queryset.model} not found!")
# Fall back to the hard-coded serializer class # Fall back to the hard-coded serializer class
logger.debug(f"Using serializer {self.serializer_class}") logger.debug(f"Using serializer {self.serializer_class}")
return 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): def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs) super().initial(request, *args, **kwargs)

View File

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

View File

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

View File

@ -14,21 +14,21 @@ body {
.wrapper { .wrapper {
min-height: 100%; min-height: 100%;
height: auto !important; 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; padding-bottom: 30px;
} }
.navbar-brand { .navbar-brand {
padding: 12px 15px 8px; padding: 12px 15px 8px;
} }
.footer, .push { .footer, .push {
height: 60px; /* .push must be the same height as .footer */ height: 48px; /* .push must be the same height as .footer */
} }
.footer { .footer {
background-color: #f5f5f5; background-color: #f5f5f5;
border-top: 1px solid #d0d0d0; border-top: 1px solid #d0d0d0;
} }
footer p { footer p {
margin: 20px 0; margin: 12px 0;
} }
#navbar_search { #navbar_search {
padding: 0 8px; padding: 0 8px;

View File

@ -100,7 +100,7 @@
</table> </table>
</div> </div>
{% include 'inc/custom_fields_panel.html' %} {% 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 panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Comments</strong> <strong>Comments</strong>

View File

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

View File

@ -165,7 +165,7 @@
<td>Cable</td> <td>Cable</td>
<td> <td>
<a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a> <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> <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a> </a>
</td> </td>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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