mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
commit
5a32b9599a
@ -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
|
||||||
|
|
||||||
|
@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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,
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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',
|
||||||
|
@ -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'
|
||||||
|
@ -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 = """
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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)
|
||||||
|
@ -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"
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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');
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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',
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -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']
|
||||||
|
@ -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]:
|
||||||
|
Loading…
Reference in New Issue
Block a user