Merge pull request #4816 from netbox-community/develop

Release v2.8.7
This commit is contained in:
Jeremy Stretch 2020-07-02 09:42:41 -04:00 committed by GitHub
commit 1c5af01a82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 644 additions and 471 deletions

View File

@ -156,9 +156,13 @@ direction = ChoiceVar(choices=CHOICES)
### ObjectVar ### ObjectVar
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type. A NetBox object of a particular type, identified by the associated queryset. Most models will utilize the REST API to retrieve available options: Note that any filtering on the queryset in this case has no effect.
* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) * `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model
### MultiObjectVar
Similar to `ObjectVar`, but allows for the selection of multiple objects.
### FileVar ### FileVar
@ -222,10 +226,7 @@ class NewBranchScript(Script):
) )
switch_model = ObjectVar( switch_model = ObjectVar(
description="Access switch model", description="Access switch model",
queryset = DeviceType.objects.filter( queryset = DeviceType.objects.all()
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
) )
def run(self, data, commit): def run(self, data, commit):

View File

@ -382,6 +382,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
--- ---
## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
Default: 22
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
---
## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
Default: 220
Default width (in pixels) of a unit within a rack elevation.
---
## REMOTE_AUTH_ENABLED ## REMOTE_AUTH_ENABLED
Default: `False` Default: `False`

View File

@ -44,11 +44,7 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model. Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
## 6. Add choices to API view ## 6. Add field to forms
If the new field has static choices, add it to the `FieldChoicesViewSet` for the app.
## 7. Add field to forms
Extend any forms to include the new field as appropriate. Common forms include: Extend any forms to include the new field as appropriate. Common forms include:
@ -57,19 +53,19 @@ Extend any forms to include the new field as appropriate. Common forms include:
* **CSV import** - The form used when bulk importing objects in CSV format * **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API) * **Filter** - Displays the options available for filtering a list of objects (both UI and API)
## 8. Extend object filter set ## 7. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
## 9. Add column to object table ## 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column. If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
## 10. Update the UI templates ## 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated. Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 11. Create/extend test cases ## 10. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including: Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:

View File

@ -1,5 +1,24 @@
# NetBox v2.8 # NetBox v2.8
## v2.8.7 (2020-07-02)
### Enhancements
* [#4796](https://github.com/netbox-community/netbox/issues/4796) - Introduce configuration parameters for default rack elevation size
* [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results
### Bug Fixes
* [#4695](https://github.com/netbox-community/netbox/issues/4695) - Expose cable termination type choices in OpenAPI spec
* [#4708](https://github.com/netbox-community/netbox/issues/4708) - Relax connection constraints for multi-position rear ports
* [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified
* [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects
* [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint
* [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays
* [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates
---
## v2.8.6 (2020-06-15) ## v2.8.6 (2020-06-15)
### Enhancements ### Enhancements

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
@ -185,10 +186,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=RackElevationDetailRenderChoices.RENDER_JSON default=RackElevationDetailRenderChoices.RENDER_JSON
) )
unit_width = serializers.IntegerField( unit_width = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
) )
unit_height = serializers.IntegerField( unit_height = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
) )
legend_width = serializers.IntegerField( legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT

View File

@ -29,6 +29,7 @@ from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
) )
from utilities.utils import get_subquery from utilities.utils import get_subquery
from utilities.metadata import ContentTypeMetadata
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import serializers from . import serializers
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
# #
class CableViewSet(ModelViewSet): class CableViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related( queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b' 'termination_a', 'termination_b'
) )

View File

@ -260,6 +260,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_515P = 'nema-5-15p'
TYPE_NEMA_520P = 'nema-5-20p' TYPE_NEMA_520P = 'nema-5-20p'
TYPE_NEMA_530P = 'nema-5-30p' TYPE_NEMA_530P = 'nema-5-30p'
@ -268,16 +269,27 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_620P = 'nema-6-20p' TYPE_NEMA_620P = 'nema-6-20p'
TYPE_NEMA_630P = 'nema-6-30p' TYPE_NEMA_630P = 'nema-6-30p'
TYPE_NEMA_650P = 'nema-6-50p' TYPE_NEMA_650P = 'nema-6-50p'
TYPE_NEMA_1030P = 'nema-10-30p'
TYPE_NEMA_1050P = 'nema-10-50p'
TYPE_NEMA_1420P = 'nema-14-20p'
TYPE_NEMA_1430P = 'nema-14-30p'
TYPE_NEMA_1450P = 'nema-14-50p'
TYPE_NEMA_1460P = 'nema-14-60p'
# NEMA locking # NEMA locking
TYPE_NEMA_L115P = 'nema-l1-15p'
TYPE_NEMA_L515P = 'nema-l5-15p' TYPE_NEMA_L515P = 'nema-l5-15p'
TYPE_NEMA_L520P = 'nema-l5-20p' TYPE_NEMA_L520P = 'nema-l5-20p'
TYPE_NEMA_L530P = 'nema-l5-30p' TYPE_NEMA_L530P = 'nema-l5-30p'
TYPE_NEMA_L615P = 'nema-l5-50p' TYPE_NEMA_L550P = 'nema-l5-50p'
TYPE_NEMA_L615P = 'nema-l6-15p'
TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L630P = 'nema-l6-30p'
TYPE_NEMA_L650P = 'nema-l6-50p' TYPE_NEMA_L650P = 'nema-l6-50p'
TYPE_NEMA_L1030P = 'nema-l10-30p'
TYPE_NEMA_L1420P = 'nema-l14-20p' TYPE_NEMA_L1420P = 'nema-l14-20p'
TYPE_NEMA_L1430P = 'nema-l14-30p' TYPE_NEMA_L1430P = 'nema-l14-30p'
TYPE_NEMA_L1450P = 'nema-l14-50p'
TYPE_NEMA_L1460P = 'nema-l14-60p'
TYPE_NEMA_L2120P = 'nema-l21-20p' TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p' TYPE_NEMA_L2130P = 'nema-l21-30p'
# California style # California style
@ -324,6 +336,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'),
(TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_520P, 'NEMA 5-20P'),
(TYPE_NEMA_530P, 'NEMA 5-30P'), (TYPE_NEMA_530P, 'NEMA 5-30P'),
@ -332,17 +345,28 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_620P, 'NEMA 6-20P'), (TYPE_NEMA_620P, 'NEMA 6-20P'),
(TYPE_NEMA_630P, 'NEMA 6-30P'), (TYPE_NEMA_630P, 'NEMA 6-30P'),
(TYPE_NEMA_650P, 'NEMA 6-50P'), (TYPE_NEMA_650P, 'NEMA 6-50P'),
(TYPE_NEMA_1030P, 'NEMA 10-30P'),
(TYPE_NEMA_1050P, 'NEMA 10-50P'),
(TYPE_NEMA_1420P, 'NEMA 14-20P'),
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
)), )),
('NEMA (Locking)', ( ('NEMA (Locking)', (
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
(TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'),
(TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'),
(TYPE_NEMA_L530P, 'NEMA L5-30P'), (TYPE_NEMA_L530P, 'NEMA L5-30P'),
(TYPE_NEMA_L550P, 'NEMA L5-50P'),
(TYPE_NEMA_L615P, 'NEMA L6-15P'), (TYPE_NEMA_L615P, 'NEMA L6-15P'),
(TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'),
(TYPE_NEMA_L650P, 'NEMA L6-50P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'),
(TYPE_NEMA_L1030P, 'NEMA L10-30P'),
(TYPE_NEMA_L1420P, 'NEMA L14-20P'), (TYPE_NEMA_L1420P, 'NEMA L14-20P'),
(TYPE_NEMA_L1430P, 'NEMA L14-30P'), (TYPE_NEMA_L1430P, 'NEMA L14-30P'),
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'), (TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
)), )),
@ -397,6 +421,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_515R = 'nema-5-15r'
TYPE_NEMA_520R = 'nema-5-20r' TYPE_NEMA_520R = 'nema-5-20r'
TYPE_NEMA_530R = 'nema-5-30r' TYPE_NEMA_530R = 'nema-5-30r'
@ -405,16 +430,27 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_620R = 'nema-6-20r' TYPE_NEMA_620R = 'nema-6-20r'
TYPE_NEMA_630R = 'nema-6-30r' TYPE_NEMA_630R = 'nema-6-30r'
TYPE_NEMA_650R = 'nema-6-50r' TYPE_NEMA_650R = 'nema-6-50r'
TYPE_NEMA_1030R = 'nema-10-30r'
TYPE_NEMA_1050R = 'nema-10-50r'
TYPE_NEMA_1420R = 'nema-14-20r'
TYPE_NEMA_1430R = 'nema-14-30r'
TYPE_NEMA_1450R = 'nema-14-50r'
TYPE_NEMA_1460R = 'nema-14-60r'
# NEMA locking # NEMA locking
TYPE_NEMA_L115R = 'nema-l1-15r'
TYPE_NEMA_L515R = 'nema-l5-15r' TYPE_NEMA_L515R = 'nema-l5-15r'
TYPE_NEMA_L520R = 'nema-l5-20r' TYPE_NEMA_L520R = 'nema-l5-20r'
TYPE_NEMA_L530R = 'nema-l5-30r' TYPE_NEMA_L530R = 'nema-l5-30r'
TYPE_NEMA_L615R = 'nema-l5-50r' TYPE_NEMA_L550R = 'nema-l5-50r'
TYPE_NEMA_L615R = 'nema-l6-15r'
TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L630R = 'nema-l6-30r'
TYPE_NEMA_L650R = 'nema-l6-50r' TYPE_NEMA_L650R = 'nema-l6-50r'
TYPE_NEMA_L1030R = 'nema-l10-30r'
TYPE_NEMA_L1420R = 'nema-l14-20r' TYPE_NEMA_L1420R = 'nema-l14-20r'
TYPE_NEMA_L1430R = 'nema-l14-30r' TYPE_NEMA_L1430R = 'nema-l14-30r'
TYPE_NEMA_L1450R = 'nema-l14-50r'
TYPE_NEMA_L1460R = 'nema-l14-60r'
TYPE_NEMA_L2120R = 'nema-l21-20r' TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r' TYPE_NEMA_L2130R = 'nema-l21-30r'
# California style # California style
@ -462,6 +498,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'),
(TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_520R, 'NEMA 5-20R'),
(TYPE_NEMA_530R, 'NEMA 5-30R'), (TYPE_NEMA_530R, 'NEMA 5-30R'),
@ -470,17 +507,28 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_620R, 'NEMA 6-20R'), (TYPE_NEMA_620R, 'NEMA 6-20R'),
(TYPE_NEMA_630R, 'NEMA 6-30R'), (TYPE_NEMA_630R, 'NEMA 6-30R'),
(TYPE_NEMA_650R, 'NEMA 6-50R'), (TYPE_NEMA_650R, 'NEMA 6-50R'),
(TYPE_NEMA_1030R, 'NEMA 10-30R'),
(TYPE_NEMA_1050R, 'NEMA 10-50R'),
(TYPE_NEMA_1420R, 'NEMA 14-20R'),
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
)), )),
('NEMA (Locking)', ( ('NEMA (Locking)', (
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
(TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'),
(TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'),
(TYPE_NEMA_L530R, 'NEMA L5-30R'), (TYPE_NEMA_L530R, 'NEMA L5-30R'),
(TYPE_NEMA_L550R, 'NEMA L5-50R'),
(TYPE_NEMA_L615R, 'NEMA L6-15R'), (TYPE_NEMA_L615R, 'NEMA L6-15R'),
(TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'),
(TYPE_NEMA_L650R, 'NEMA L6-50R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'),
(TYPE_NEMA_L1030R, 'NEMA L10-30R'),
(TYPE_NEMA_L1420R, 'NEMA L14-20R'), (TYPE_NEMA_L1420R, 'NEMA L14-20R'),
(TYPE_NEMA_L1430R, 'NEMA L14-30R'), (TYPE_NEMA_L1430R, 'NEMA L14-30R'),
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'), (TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
)), )),

View File

@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
# #

View File

@ -1026,6 +1026,30 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Device component templates # Device component templates
# #
class ComponentTemplateCreateForm(BootstrapMixin, forms.Form):
"""
Base form for the creation of device component templates.
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
widget=APISelect(
filter_for={
'device_type': 'manufacturer_id'
}
)
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
widget=APISelect(
display_field='model'
)
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
@ -1038,13 +1062,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2() widget=StaticSelect2()
@ -1078,13 +1096,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2() widget=StaticSelect2()
@ -1118,13 +1130,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices), choices=add_blank_choice(PowerPortTypeChoices),
required=False required=False
@ -1188,13 +1194,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
) )
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False required=False
@ -1275,13 +1275,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
widget=StaticSelect2() widget=StaticSelect2()
@ -1335,13 +1329,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
) )
class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2() widget=StaticSelect2()
@ -1426,13 +1414,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -1472,13 +1454,8 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField( pass
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet # TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet
@ -2208,9 +2185,21 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
# #
# Bulk device component creation # Device components
# #
class ComponentCreateForm(BootstrapMixin, forms.Form):
"""
Base form for the creation of device components.
"""
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -2256,13 +2245,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
} }
class ConsolePortCreateForm(BootstrapMixin, forms.Form): class ConsolePortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
required=False, required=False,
@ -2342,13 +2325,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
} }
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
required=False, required=False,
@ -2442,13 +2419,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
} }
class PowerPortCreateForm(BootstrapMixin, forms.Form): class PowerPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices), choices=add_blank_choice(PowerPortTypeChoices),
required=False, required=False,
@ -2551,13 +2522,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
) )
class PowerOutletCreateForm(BootstrapMixin, forms.Form): class PowerOutletCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False, required=False,
@ -2776,13 +2741,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -3059,13 +3018,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(BootstrapMixin, forms.Form): class FrontPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -3239,13 +3192,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
} }
class RearPortCreateForm(BootstrapMixin, forms.Form): class RearPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -3341,13 +3288,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
} }
class DeviceBayCreateForm(BootstrapMixin, forms.Form): class DeviceBayCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
tags = TagField( tags = TagField(
required=False required=False
) )

View File

@ -731,8 +731,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_elevation_svg( def get_elevation_svg(
self, self,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True, include_images=True,
base_url=None base_url=None
@ -2129,6 +2129,7 @@ class Cable(ChangeLoggedModel):
return reverse('dcim:cable', args=[self.pk]) return reverse('dcim:cable', args=[self.pk])
def clean(self): def clean(self):
from circuits.models import CircuitTermination
# Validate that termination A exists # Validate that termination A exists
if not hasattr(self, 'termination_a_type'): if not hasattr(self, 'termination_a_type'):
@ -2191,19 +2192,21 @@ class Cable(ChangeLoggedModel):
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
) )
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions # Check that a RearPort with multiple positions isn't connected to an endpoint
# or a RearPort with a different number of positions.
for term_a, term_b in [ for term_a, term_b in [
(self.termination_a, self.termination_b), (self.termination_a, self.termination_b),
(self.termination_b, self.termination_a) (self.termination_b, self.termination_a)
]: ]:
if isinstance(term_a, RearPort) and term_a.positions > 1: if isinstance(term_a, RearPort) and term_a.positions > 1:
if not isinstance(term_b, RearPort): if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
raise ValidationError( raise ValidationError(
"Rear ports with multiple positions may only be connected to other rear ports" "Rear ports with multiple positions may only be connected to other pass-through ports"
) )
elif term_a.positions != term_b.positions: if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
raise ValidationError( raise ValidationError(
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. " f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
f"{term_b} of {term_b.device} has {term_b.positions}. "
f"Both terminations must have the same number of positions." f"Both terminations must have the same number of positions."
) )

View File

@ -44,6 +44,9 @@ class ComponentModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def __str__(self):
return getattr(self, 'name')
def to_objectchange(self, action): def to_objectchange(self, action):
# Annotate the parent Device/VM # Annotate the parent Device/VM
try: try:
@ -86,16 +89,16 @@ class CableTermination(models.Model):
object_id_field='termination_b_id' object_id_field='termination_b_id'
) )
is_path_endpoint = True
class Meta: class Meta:
abstract = True abstract = True
def trace(self): def trace(self):
""" """
Return two items: the traceable portion of a cable path, and the termination points where it splits (if any). Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow. along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
a FrontPort without traversing a RearPort again.
The path is a list representing a complete cable path, with each individual segment represented as a The path is a list representing a complete cable path, with each individual segment represented as a
three-tuple: three-tuple:
@ -115,26 +118,35 @@ class CableTermination(models.Model):
# Map a front port to its corresponding rear port # Map a front port to its corresponding rear port
if isinstance(termination, FrontPort): if isinstance(termination, FrontPort):
position_stack.append(termination.rear_port_position)
# Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
peer_port = RearPort.objects.get(pk=termination.rear_port.pk) peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
# Don't use the stack for RearPorts with a single position. Only remember the position at
# many-to-one points so we can select the correct FrontPort when we reach the corresponding
# one-to-many point.
if peer_port.positions > 1:
position_stack.append(termination)
return peer_port return peer_port
# Map a rear port/position to its corresponding front port # Map a rear port/position to its corresponding front port
elif isinstance(termination, RearPort): elif isinstance(termination, RearPort):
if termination.positions > 1:
# Can't map to a FrontPort without a position if there are multiple options # Can't map to a FrontPort without a position if there are multiple options
if termination.positions > 1 and not position_stack: if not position_stack:
raise CableTraceSplit(termination) raise CableTraceSplit(termination)
# We can assume position 1 if the RearPort has only one position front_port = position_stack.pop()
position = position_stack.pop() if position_stack else 1 position = front_port.rear_port_position
# Validate the position # Validate the position
if position not in range(1, termination.positions + 1): if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format( raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position termination, termination.positions, position
)) ))
else:
# Don't use the stack for RearPorts with a single position. The only possible position is 1.
position = 1
try: try:
peer_port = FrontPort.objects.get( peer_port = FrontPort.objects.get(
@ -165,12 +177,12 @@ class CableTermination(models.Model):
if not endpoint.cable: if not endpoint.cable:
path.append((endpoint, None, None)) path.append((endpoint, None, None))
logger.debug("No cable connected") logger.debug("No cable connected")
return path, None return path, None, position_stack
# Check for loops # Check for loops
if endpoint.cable in [segment[1] for segment in path]: if endpoint.cable in [segment[1] for segment in path]:
logger.debug("Loop detected!") logger.debug("Loop detected!")
return path, None return path, None, position_stack
# Record the current segment in the path # Record the current segment in the path
far_end = endpoint.get_cable_peer() far_end = endpoint.get_cable_peer()
@ -183,10 +195,10 @@ class CableTermination(models.Model):
try: try:
endpoint = get_peer_port(far_end) endpoint = get_peer_port(far_end)
except CableTraceSplit as e: except CableTraceSplit as e:
return path, e.termination.frontports.all() return path, e.termination.frontports.all(), position_stack
if endpoint is None: if endpoint is None:
return path, None return path, None, position_stack
def get_cable_peer(self): def get_cable_peer(self):
if self.cable is None: if self.cable is None:
@ -203,7 +215,7 @@ class CableTermination(models.Model):
endpoints = [] endpoints = []
# Get the far end of the last path segment # Get the far end of the last path segment
path, split_ends = self.trace() path, split_ends, position_stack = self.trace()
endpoint = path[-1][2] endpoint = path[-1][2]
if split_ends is not None: if split_ends is not None:
for termination in split_ends: for termination in split_ends:
@ -261,9 +273,6 @@ class ConsolePort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -316,9 +325,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -397,9 +403,6 @@ class PowerPort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -547,9 +550,6 @@ class PowerOutlet(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -685,9 +685,6 @@ class Interface(CableTermination, ComponentModel):
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk}) return reverse('dcim:interface', kwargs={'pk': self.pk})
@ -884,7 +881,6 @@ class FrontPort(CableTermination, ComponentModel):
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
is_path_endpoint = False
class Meta: class Meta:
ordering = ('device', '_name') ordering = ('device', '_name')
@ -893,9 +889,6 @@ class FrontPort(CableTermination, ComponentModel):
('rear_port', 'rear_port_position'), ('rear_port', 'rear_port_position'),
) )
def __str__(self):
return self.name
def to_csv(self): def to_csv(self):
return ( return (
self.device.identifier, self.device.identifier,
@ -952,15 +945,11 @@ class RearPort(CableTermination, ComponentModel):
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description'] csv_headers = ['device', 'name', 'type', 'positions', 'description']
is_path_endpoint = False
class Meta: class Meta:
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def to_csv(self): def to_csv(self):
return ( return (
self.device.identifier, self.device.identifier,
@ -1009,9 +998,6 @@ class DeviceBay(ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()

View File

@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from .choices import CableStatusChoices from .choices import CableStatusChoices
from .models import Cable, Device, VirtualChassis from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis
@receiver(post_save, sender=VirtualChassis) @receiver(post_save, sender=VirtualChassis)
@ -52,7 +52,7 @@ def update_connected_endpoints(instance, **kwargs):
# Update any endpoints for this Cable. # Update any endpoints for this Cable.
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
for endpoint in endpoints: for endpoint in endpoints:
path, split_ends = endpoint.trace() path, split_ends, position_stack = endpoint.trace()
# Determine overall path status (connected or planned) # Determine overall path status (connected or planned)
path_status = True path_status = True
for segment in path: for segment in path:
@ -61,9 +61,11 @@ def update_connected_endpoints(instance, **kwargs):
break break
endpoint_a = path[0][0] endpoint_a = path[0][0]
endpoint_b = path[-1][2] endpoint_b = path[-1][2] if not split_ends and not position_stack else None
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): # Patch panel ports are not connected endpoints, all other cable terminations are
if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = endpoint_b endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status endpoint_a.connection_status = path_status

View File

@ -363,6 +363,7 @@ class CableTestCase(TestCase):
) )
self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
self.cable.save() self.cable.save()
@ -370,10 +371,27 @@ class CableTestCase(TestCase):
self.patch_pannel = Device.objects.create( self.patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
) )
self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000) self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
self.front_port = FrontPort.objects.create( self.front_port1 = FrontPort.objects.create(
device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
) )
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
self.front_port2 = FrontPort.objects.create(
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
)
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
self.front_port3 = FrontPort.objects.create(
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
)
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
self.front_port4 = FrontPort.objects.create(
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
)
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000)
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000)
def test_cable_creation(self): def test_cable_creation(self):
""" """
@ -405,7 +423,7 @@ class CableTestCase(TestCase):
cable = Cable.objects.filter(pk=self.cable.pk).first() cable = Cable.objects.filter(pk=self.cable.pk).first()
self.assertIsNone(cable) self.assertIsNone(cable)
def test_cable_validates_compatibale_types(self): def test_cable_validates_compatible_types(self):
""" """
The clean method should have a check to ensure only compatible port types can be connected by a cable The clean method should have a check to ensure only compatible port types can be connected by a cable
""" """
@ -426,7 +444,7 @@ class CableTestCase(TestCase):
""" """
A cable cannot connect a front port to its corresponding rear port A cable cannot connect a front port to its corresponding rear port
""" """
cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
@ -439,7 +457,94 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_cable_cannot_terminate_to_a_virtual_inteface(self): def test_connection_via_single_position_rearport(self):
"""
A RearPort with one position can be connected to anything.
[CableTermination X]---[RP(pos=1) FP]---[CableTermination Y]
is allowed anywhere
[CableTermination X]---[CableTermination Y]
is allowed.
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions. RearPorts with a single position on the other hand may be connected
to such CableTerminations. Check that this is indeed allowed.
"""
# Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
# Connecting a single-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
# Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
def test_connection_via_multi_position_rearport(self):
"""
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions.
The following scenario's are allowed (with x>1):
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=x)
| |
~----------+ +---------~
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=1)
| |
~----------+ +---------~
~----------+ +------------------~
| |
RP2(pos=x)|---|CircuitTermination
| |
~----------+ +------------------~
These scenarios are NOT allowed (with x>1):
~----------+ +----------~
| |
RP2(pos=x)|---|RP(pos!=x)
| |
~----------+ +----------~
~----------+ +----------~
| |
RP2(pos=x)|---|Interface
| |
~----------+ +----------~
These scenarios are tested in this order below.
"""
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to a single-position RearPort is ok
Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a multi-position RearPort to an Interface should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self):
""" """
A cable cannot terminate to a virtual interface A cable cannot terminate to a virtual interface
""" """
@ -448,7 +553,7 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() cable.clean()
def test_cable_cannot_terminate_to_a_wireless_inteface(self): def test_cable_cannot_terminate_to_a_wireless_interface(self):
""" """
A cable cannot terminate to a wireless interface A cable cannot terminate to a wireless interface
""" """
@ -501,9 +606,13 @@ class CablePathTestCase(TestCase):
Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site), Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site),
) )
Device.objects.bulk_create(patch_panels) Device.objects.bulk_create(patch_panels)
for patch_panel in patch_panels:
# Create patch panels with 4 positions
for patch_panel in patch_panels[:4]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C) rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.bulk_create(( FrontPort.objects.bulk_create((
FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C), FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
@ -512,6 +621,11 @@ class CablePathTestCase(TestCase):
FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C), FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
)) ))
# Create 1-on-1 patch panels
for patch_panel in patch_panels[4:]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C)
def test_direct_connection(self): def test_direct_connection(self):
""" """
Test a direct connection between two interfaces. Test a direct connection between two interfaces.
@ -524,6 +638,7 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
cable.full_clean()
cable.save() cable.save()
# Retrieve endpoints # Retrieve endpoints
@ -551,22 +666,25 @@ class CablePathTestCase(TestCase):
def test_connection_via_single_rear_port(self): def test_connection_via_single_rear_port(self):
""" """
Test a connection which passes through a single front/rear port pair. Test a connection which passes through a rear port with exactly one front port.
1 2 1 2
[Device 1] ----- [Panel 1] ----- [Device 2] [Device 1] ----- [Panel 5] ----- [Device 2]
Iface1 FP1 RP1 Iface1 Iface1 FP1 RP1 Iface1
""" """
# Create cables # Create cables (FP first, RP second)
cable1 = Cable( cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
self.assertEqual(cable2.termination_a.positions, 1) # Sanity check
cable2.full_clean()
cable2.save() cable2.save()
# Retrieve endpoints # Retrieve endpoints
@ -592,6 +710,97 @@ class CablePathTestCase(TestCase):
self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status) self.assertIsNone(endpoint_b.connection_status)
def test_connections_via_nested_single_position_rearport(self):
"""
Test a connection which passes through a single front/rear port pair between two multi-position rear ports.
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 | FP1
[Panel 1] ----- [Panel 5] ----- [Panel 2]
FP2 | RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
5 6
"""
# Create cables (Panel 5 RP first, FP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable6.full_clean()
cable6.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_patch(self): def test_connections_via_patch(self):
""" """
Test two connections via patched rear ports: Test two connections via patched rear ports:
@ -613,28 +822,33 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
) )
cable2.full_clean()
cable2.save() cable2.save()
cable3 = Cable( cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
) )
cable3.full_clean()
cable3.save() cable3.save()
cable4 = Cable( cable4 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
) )
cable4.full_clean()
cable4.save() cable4.save()
cable5 = Cable( cable5 = Cable(
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2') termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
) )
cable5.full_clean()
cable5.save() cable5.save()
# Retrieve endpoints # Retrieve endpoints
@ -693,43 +907,51 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
) )
cable2.full_clean()
cable2.save() cable2.save()
cable3 = Cable( cable3 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
cable3.full_clean()
cable3.save() cable3.save()
cable4 = Cable( cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
) )
cable4.full_clean()
cable4.save() cable4.save()
cable5 = Cable( cable5 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
) )
cable5.full_clean()
cable5.save() cable5.save()
cable6 = Cable( cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
) )
cable6.full_clean()
cable6.save() cable6.save()
cable7 = Cable( cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2') termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
) )
cable7.full_clean()
cable7.save() cable7.save()
cable8 = Cable( cable8 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
) )
cable8.full_clean()
cable8.save() cable8.save()
# Retrieve endpoints # Retrieve endpoints
@ -789,38 +1011,45 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
cable2.full_clean()
cable2.save() cable2.save()
cable3 = Cable( cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
) )
cable3.full_clean()
cable3.save() cable3.save()
cable4 = Cable( cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
) )
cable4.full_clean()
cable4.save() cable4.save()
cable5 = Cable( cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
) )
cable5.full_clean()
cable5.save() cable5.save()
cable6 = Cable( cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
) )
cable6.full_clean()
cable6.save() cable6.save()
cable7 = Cable( cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
) )
cable7.full_clean()
cable7.save() cable7.save()
# Retrieve endpoints # Retrieve endpoints
@ -870,11 +1099,13 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=CircuitTermination.objects.get(term_side='A') termination_b=CircuitTermination.objects.get(term_side='A')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'), termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
cable2.full_clean()
cable2.save() cable2.save()
# Retrieve endpoints # Retrieve endpoints
@ -903,30 +1134,34 @@ class CablePathTestCase(TestCase):
def test_connection_via_patched_circuit(self): def test_connection_via_patched_circuit(self):
""" """
1 2 3 4 1 2 3 4
[Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2] [Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2]
Iface1 FP1 RP1 A Z RP1 FP1 Iface1 Iface1 FP1 RP1 A Z RP1 FP1 Iface1
""" """
# Create cables # Create cables
cable1 = Cable( cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
) )
cable1.full_clean()
cable1.save() cable1.save()
cable2 = Cable( cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=CircuitTermination.objects.get(term_side='A') termination_b=CircuitTermination.objects.get(term_side='A')
) )
cable2.full_clean()
cable2.save() cable2.save()
cable3 = Cable( cable3 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'), termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1')
) )
cable3.full_clean()
cable3.save() cable3.save()
cable4 = Cable( cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
) )
cable4.full_clean()
cable4.save() cable4.save()
# Retrieve endpoints # Retrieve endpoints

View File

@ -2057,7 +2057,7 @@ class CableTraceView(PermissionRequiredMixin, View):
def get(self, request, model, pk): def get(self, request, model, pk):
obj = get_object_or_404(model, pk=pk) obj = get_object_or_404(model, pk=pk)
path, split_ends = obj.trace() path, split_ends, position_stack = obj.trace()
total_length = sum( total_length = sum(
[entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
) )
@ -2066,6 +2066,7 @@ class CableTraceView(PermissionRequiredMixin, View):
'obj': obj, 'obj': obj,
'trace': path, 'trace': path,
'split_ends': split_ends, 'split_ends': split_ends,
'position_stack': position_stack,
'total_length': total_length, 'total_length': total_length,
}) })

View File

@ -167,8 +167,14 @@ class AddRemoveTagsForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add add/remove tags fields # Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False) self.fields['add_tags'] = DynamicModelMultipleChoiceField(
self.fields['remove_tags'] = TagField(required=False) queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class TagFilterForm(BootstrapMixin, forms.Form): class TagFilterForm(BootstrapMixin, forms.Form):

View File

@ -6,6 +6,7 @@ from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
@ -52,6 +53,7 @@ class Command(BaseCommand):
pass pass
# Additional objects to include # Additional objects to include
namespace['ContentType'] = ContentType
namespace['User'] = User namespace['User'] = User
# Load convenience commands # Load convenience commands

View File

@ -167,7 +167,7 @@ class ChoiceVar(ScriptVariable):
class ObjectVar(ScriptVariable): class ObjectVar(ScriptVariable):
""" """
NetBox object representation. The provided QuerySet will determine the choices available. A single object within NetBox.
""" """
form_field = DynamicModelChoiceField form_field = DynamicModelChoiceField

View File

@ -208,6 +208,10 @@ PLUGINS = []
# prefer IPv4 instead. # prefer IPv4 instead.
PREFER_IPV4 = False PREFER_IPV4 = False
# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1.
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220
# Remote authentication support # Remote authentication support
REMOTE_AUTH_ENABLED = False REMOTE_AUTH_ENABLED = False
REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend' REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend'

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.8.6' VERSION = '2.8.7'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -99,6 +99,8 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22)
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend') REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])

View File

@ -183,13 +183,6 @@ nav ul.pagination {
margin-bottom: 8px !important; margin-bottom: 8px !important;
} }
/* Racks */
div.rack_header {
margin-left: 32px;
text-align: center;
width: 220px;
}
/* Devices */ /* Devices */
table.component-list td.subtable { table.component-list td.subtable {
padding: 0; padding: 0;

View File

@ -1,13 +1,22 @@
from rest_framework import serializers from rest_framework import serializers
from secrets.models import SecretRole from secrets.models import Secret, SecretRole
from utilities.api import WritableNestedSerializer from utilities.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedSecretRoleSerializer' 'NestedSecretRoleSerializer',
'NestedSecretSerializer',
] ]
class NestedSecretSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
class Meta:
model = Secret
fields = ['id', 'url', 'name']
class NestedSecretRoleSerializer(WritableNestedSerializer): class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
secret_count = serializers.IntegerField(read_only=True) secret_count = serializers.IntegerField(read_only=True)

View File

@ -49,212 +49,68 @@ class SecretRoleTest(APIViewTestCases.APIViewTestCase):
SecretRole.objects.bulk_create(secret_roles) SecretRole.objects.bulk_create(secret_roles)
# TODO: Standardize SecretTest class SecretTest(APIViewTestCases.APIViewTestCase):
class SecretTest(APITestCase): model = Secret
brief_fields = ['id', 'name', 'url']
def setUp(self): def setUp(self):
super().setUp()
# Create a non-superuser test user # Create a UserKey for the test user
self.user = create_test_user('testuser', permissions=(
'secrets.add_secret',
'secrets.change_secret',
'secrets.delete_secret',
'secrets.view_secret',
))
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save() userkey.save()
# Create a SessionKey for the user
self.master_key = userkey.get_master_key(PRIVATE_KEY) self.master_key = userkey.get_master_key(PRIVATE_KEY)
session_key = SessionKey(userkey=userkey) session_key = SessionKey(userkey=userkey)
session_key.save(self.master_key) session_key.save(self.master_key)
self.header = { # Append the session key to the test client's request header
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), self.header['HTTP_X_SESSION_KEY'] = base64.b64encode(session_key.key)
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
}
self.plaintexts = ( site = Site.objects.create(name='Site 1', slug='site-1')
'Secret #1 Plaintext', manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
'Secret #2 Plaintext', devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
'Secret #3 Plaintext', devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
secret_roles = (
SecretRole(name='Secret Role 1', slug='secret-role-1'),
SecretRole(name='Secret Role 2', slug='secret-role-2'),
) )
SecretRole.objects.bulk_create(secret_roles)
site = Site.objects.create(name='Test Site 1', slug='test-site-1') secrets = (
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') Secret(device=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'),
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type 1') Secret(device=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'),
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1') Secret(device=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'),
self.device = Device.objects.create(
name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole
) )
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') for secret in secrets:
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') secret.encrypt(self.master_key)
self.secret1 = Secret( secret.save()
device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0]
)
self.secret1.encrypt(self.master_key)
self.secret1.save()
self.secret2 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1]
)
self.secret2.encrypt(self.master_key)
self.secret2.save()
self.secret3 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2]
)
self.secret3.encrypt(self.master_key)
self.secret3.save()
def test_get_secret(self): self.create_data = [
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
# Secret plaintext not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
self.assertIsNone(response.data['plaintext'])
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['plaintext'], self.plaintexts[0])
def test_list_secrets(self):
url = reverse('secrets-api:secret-list')
# Secret plaintext not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
for secret in response.data['results']:
self.assertIsNone(secret['plaintext'])
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
for i, secret in enumerate(response.data['results']):
self.assertEqual(secret['plaintext'], self.plaintexts[i])
def test_create_secret(self):
data = {
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext',
}
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['plaintext'], data['plaintext'])
self.assertEqual(Secret.objects.count(), 4)
secret4 = Secret.objects.get(pk=response.data['id'])
secret4.decrypt(self.master_key)
self.assertEqual(secret4.role_id, data['role'])
self.assertEqual(secret4.plaintext, data['plaintext'])
def test_create_secret_bulk(self):
data = [
{ {
'device': self.device.pk, 'device': device.pk,
'role': self.secretrole1.pk, 'role': secret_roles[1].pk,
'name': 'Test Secret 4', 'name': 'Secret 4',
'plaintext': 'Secret #4 Plaintext', 'plaintext': 'JKL',
}, },
{ {
'device': self.device.pk, 'device': device.pk,
'role': self.secretrole1.pk, 'role': secret_roles[1].pk,
'name': 'Test Secret 5', 'name': 'Secret 5',
'plaintext': 'Secret #5 Plaintext', 'plaintext': 'MNO',
}, },
{ {
'device': self.device.pk, 'device': device.pk,
'role': self.secretrole1.pk, 'role': secret_roles[1].pk,
'name': 'Test Secret 6', 'name': 'Secret 6',
'plaintext': 'Secret #6 Plaintext', 'plaintext': 'PQR',
}, },
] ]
url = reverse('secrets-api:secret-list') def prepare_instance(self, instance):
response = self.client.post(url, data, format='json', **self.header) # Unlock the plaintext prior to evaluation of the instance
instance.decrypt(self.master_key)
self.assertHttpStatus(response, status.HTTP_201_CREATED) return instance
self.assertEqual(Secret.objects.count(), 6)
self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
def test_update_secret(self):
data = {
'device': self.device.pk,
'role': self.secretrole2.pk,
'plaintext': 'NewPlaintext',
}
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['plaintext'], data['plaintext'])
self.assertEqual(Secret.objects.count(), 3)
secret1 = Secret.objects.get(pk=response.data['id'])
secret1.decrypt(self.master_key)
self.assertEqual(secret1.role_id, data['role'])
self.assertEqual(secret1.plaintext, data['plaintext'])
def test_delete_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Secret.objects.count(), 2)
class GetSessionKeyTest(APITestCase):
def setUp(self):
super().setUp()
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
master_key = userkey.get_master_key(PRIVATE_KEY)
self.session_key = SessionKey(userkey=userkey)
self.session_key.save(master_key)
self.header = {
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
}
def test_get_session_key(self):
encoded_session_key = base64.b64encode(self.session_key.key).decode()
url = reverse('secrets-api:get-session-key-list')
data = {
'private_key': PRIVATE_KEY,
}
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIsNotNone(response.data.get('session_key'))
self.assertNotEqual(response.data.get('session_key'), encoded_session_key)
def test_get_session_key_preserved(self):
encoded_session_key = base64.b64encode(self.session_key.key).decode()
url = reverse('secrets-api:get-session-key-list') + '?preserve_key=True'
data = {
'private_key': PRIVATE_KEY,
}
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data.get('session_key'), encoded_session_key)

View File

@ -88,6 +88,16 @@
</table> </table>
</div> </div>
</div> </div>
{% elif position_stack %}
<div class="col-md-11 col-md-offset-1">
<h3 class="text-warning text-center">
{% with last_position=position_stack|last %}
Trace completed, but there is no Front Port corresponding to
<a href="{{ last_position.device.get_absolute_url }}">{{ last_position.device }}</a> {{ last_position }}.<br>
Therefore no end-to-end connection can be established.
{% endwith %}
</h3>
</div>
{% else %} {% else %}
<div class="col-md-11 col-md-offset-1"> <div class="col-md-11 col-md-offset-1">
<h3 class="text-success text-center">Trace completed!</h3> <h3 class="text-success text-center">Trace completed!</h3>

View File

@ -1,4 +1,6 @@
<div style="margin-left: -30px">
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" class="rack_elevation"></object> <object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" class="rack_elevation"></object>
</div>
<div class="text-center text-small"> <div class="text-center text-small">
<a href="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"> <a href="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg">
<i class="fa fa-download"></i> Save SVG <i class="fa fa-download"></i> Save SVG

View File

@ -318,16 +318,12 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="row" style="margin-bottom: 20px"> <div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12"> <div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div class="rack_header">
<h4>Front</h4> <h4>Front</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with face='front' %} {% include 'dcim/inc/rack_elevation.html' with face='front' %}
</div> </div>
<div class="col-md-6 col-sm-6 col-xs-12"> <div class="col-md-6 col-sm-6 col-xs-12 text-center">
<div class="rack_header">
<h4>Rear</h4> <h4>Rear</h4>
</div>
{% include 'dcim/inc/rack_elevation.html' with face='rear' %} {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
</div> </div>
</div> </div>

View File

@ -19,6 +19,7 @@
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>
{% endif %}
<form method="get"> <form method="get">
{% for k, v_list in request.GET.lists %} {% for k, v_list in request.GET.lists %}
{% if k != 'per_page' %} {% if k != 'per_page' %}
@ -33,7 +34,6 @@
{% endfor %} {% endfor %}
</select> per page </select> per page
</form> </form>
{% endif %}
{% if page %} {% if page %}
<div class="text-right text-muted"> <div class="text-right text-muted">
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }} Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}

View File

@ -50,7 +50,7 @@ class LoginView(View):
logger.debug("Login form validation was successful") logger.debug("Login form validation was successful")
# Determine where to direct user after successful login # Determine where to direct user after successful login
redirect_to = request.POST.get('next') redirect_to = request.POST.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home') redirect_to = reverse('home')

View File

@ -0,0 +1,19 @@
from rest_framework.metadata import SimpleMetadata
from django.utils.encoding import force_str
from utilities.api import ContentTypeField
class ContentTypeMetadata(SimpleMetadata):
def get_field_info(self, field):
field_info = super().get_field_info(field)
if hasattr(field, 'queryset') and not field_info.get('read_only') and isinstance(field, ContentTypeField):
field_info['choices'] = [
{
'value': choice_value,
'display_name': force_str(choice_name, strings_only=True)
}
for choice_value, choice_name in field.choices.items()
]
field_info['choices'].sort(key=lambda item: item['display_name'])
return field_info

View File

@ -26,6 +26,54 @@ class TestCase(_TestCase):
self.client = Client() self.client = Client()
self.client.force_login(self.user) self.client.force_login(self.user)
def prepare_instance(self, instance):
"""
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
"""
return instance
def model_to_dict(self, instance, fields, api=False):
"""
Return a dictionary representation of an instance.
"""
# Prepare the instance and call Django's model_to_dict() to extract all fields
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
# Map any additional (non-field) instance attributes that were specified
for attr in fields:
if hasattr(instance, attr) and attr not in model_dict:
model_dict[attr] = getattr(instance, attr)
for key, value in list(model_dict.items()):
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
if key == 'tags':
model_dict[key] = ','.join(sorted([tag.name for tag in value]))
# Convert ManyToManyField to list of instance PKs
elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
model_dict[key] = [obj.pk for obj in value]
if api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
model_dict[key] = f'{ct.app_label}.{ct.model}'
# Convert IPNetwork instances to strings
if type(value) is IPNetwork:
model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
return model_dict
# #
# Permissions management # Permissions management
# #
@ -70,34 +118,7 @@ class TestCase(_TestCase):
:data: Dictionary of test data used to define the instance :data: Dictionary of test data used to define the instance
:api: Set to True is the data is a JSON representation of the instance :api: Set to True is the data is a JSON representation of the instance
""" """
model_dict = model_to_dict(instance, fields=data.keys()) model_dict = self.model_to_dict(instance, fields=data.keys(), api=api)
for key, value in list(model_dict.items()):
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
if key == 'tags':
model_dict[key] = ','.join(sorted([tag.name for tag in value]))
# Convert ManyToManyField to list of instance PKs
elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
model_dict[key] = [obj.pk for obj in value]
if api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
model_dict[key] = f'{ct.app_label}.{ct.model}'
# Convert IPNetwork instances to strings
if type(value) is IPNetwork:
model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
# Omit any dictionary keys which are not instance attributes # Omit any dictionary keys which are not instance attributes
relevant_data = { relevant_data = {
@ -199,7 +220,7 @@ class ViewTestCases:
# Try GET without permission # Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('add')), 403) self.assertHttpStatus(self.client.get(self._get_url('add')), 403)
# Try GET with permission # Try GET with permission
self.add_permissions( self.add_permissions(
@ -235,7 +256,7 @@ class ViewTestCases:
# Try GET without permission # Try GET without permission
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403) self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 403)
# Try GET with permission # Try GET with permission
self.add_permissions( self.add_permissions(
@ -267,7 +288,7 @@ class ViewTestCases:
# Try GET without permissions # Try GET without permissions
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403) self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403)
# Try GET with permission # Try GET with permission
self.add_permissions( self.add_permissions(

View File

@ -950,6 +950,10 @@ class ComponentCreateView(GetReturnURLMixin, View):
)) ))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect(request.get_full_path()) return redirect(request.get_full_path())
elif 'device_type' in form.cleaned_data:
return redirect(form.cleaned_data['device_type'].get_absolute_url())
elif 'device' in form.cleaned_data:
return redirect(form.cleaned_data['device'].get_absolute_url())
else: else:
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))