diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 1d84fea24..496e4f7fa 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -156,9 +156,13 @@ direction = ChoiceVar(choices=CHOICES) ### 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 @@ -222,10 +226,7 @@ class NewBranchScript(Script): ) switch_model = ObjectVar( description="Access switch model", - queryset = DeviceType.objects.filter( - manufacturer__name='Cisco', - model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T'] - ) + queryset = DeviceType.objects.all() ) def run(self, data, commit): diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 8ef2b4b21..69939d6e5 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -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 Default: `False` diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 19f6ca023..d95edccf9 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -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 `.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 - -If the new field has static choices, add it to the `FieldChoicesViewSet` for the app. - -## 7. Add field to forms +## 6. Add field to forms 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 * **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. -## 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. -## 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. -## 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: diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index ca264806b..e13e06b62 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,11 +1,20 @@ # NetBox v2.8 -## v2.8.7 (FUTURE) +## 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 --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 18e35aabe..6838c3987 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -183,10 +184,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH ) unit_height = serializers.IntegerField( - default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8b88baa93..6de2a5982 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -29,6 +29,7 @@ from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery +from utilities.metadata import ContentTypeMetadata from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): # class CableViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 479563093..59f30a206 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -260,6 +260,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_520P = 'nema-5-20p' TYPE_NEMA_530P = 'nema-5-30p' @@ -268,16 +269,27 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEMA_620P = 'nema-6-20p' TYPE_NEMA_630P = 'nema-6-30p' 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 + TYPE_NEMA_L115P = 'nema-l1-15p' TYPE_NEMA_L515P = 'nema-l5-15p' TYPE_NEMA_L520P = 'nema-l5-20p' 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_L630P = 'nema-l6-30p' TYPE_NEMA_L650P = 'nema-l6-50p' + TYPE_NEMA_L1030P = 'nema-l10-30p' TYPE_NEMA_L1420P = 'nema-l14-20p' 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_L2130P = 'nema-l21-30p' # California style @@ -324,6 +336,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_530P, 'NEMA 5-30P'), @@ -332,17 +345,28 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEMA_620P, 'NEMA 6-20P'), (TYPE_NEMA_630P, 'NEMA 6-30P'), (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)', ( + (TYPE_NEMA_L115P, 'NEMA L1-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L530P, 'NEMA L5-30P'), + (TYPE_NEMA_L550P, 'NEMA L5-50P'), (TYPE_NEMA_L615P, 'NEMA L6-15P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'), + (TYPE_NEMA_L1030P, 'NEMA L10-30P'), (TYPE_NEMA_L1420P, 'NEMA L14-20P'), (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_L2130P, 'NEMA L21-30P'), )), @@ -397,6 +421,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' # NEMA non-locking + TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_520R = 'nema-5-20r' TYPE_NEMA_530R = 'nema-5-30r' @@ -405,16 +430,27 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEMA_620R = 'nema-6-20r' TYPE_NEMA_630R = 'nema-6-30r' 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 + TYPE_NEMA_L115R = 'nema-l1-15r' TYPE_NEMA_L515R = 'nema-l5-15r' TYPE_NEMA_L520R = 'nema-l5-20r' 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_L630R = 'nema-l6-30r' TYPE_NEMA_L650R = 'nema-l6-50r' + TYPE_NEMA_L1030R = 'nema-l10-30r' TYPE_NEMA_L1420R = 'nema-l14-20r' 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_L2130R = 'nema-l21-30r' # California style @@ -462,6 +498,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_3PNE9H, '3P+N+E 9H'), )), ('NEMA (Non-locking)', ( + (TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_530R, 'NEMA 5-30R'), @@ -470,17 +507,28 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEMA_620R, 'NEMA 6-20R'), (TYPE_NEMA_630R, 'NEMA 6-30R'), (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)', ( + (TYPE_NEMA_L115R, 'NEMA L1-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L530R, 'NEMA L5-30R'), + (TYPE_NEMA_L550R, 'NEMA L5-50R'), (TYPE_NEMA_L615R, 'NEMA L6-15R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'), + (TYPE_NEMA_L1030R, 'NEMA L10-30R'), (TYPE_NEMA_L1420R, 'NEMA L14-20R'), (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_L2130R, 'NEMA L21-30R'), )), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index f938b6f14..66768515c 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 -RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220 -RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22 # diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1a90e01bc..1aeedf1e5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -749,8 +749,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_elevation_svg( self, face=DeviceFaceChoices.FACE_FRONT, - unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, + unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None @@ -2175,6 +2175,7 @@ class Cable(ChangeLoggedModel): return reverse('dcim:cable', args=[self.pk]) def clean(self): + from circuits.models import CircuitTermination # Validate that termination A exists if not hasattr(self, 'termination_a_type'): @@ -2237,19 +2238,21 @@ class Cable(ChangeLoggedModel): 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 [ (self.termination_a, self.termination_b), (self.termination_b, self.termination_a) ]: 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( - "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( - 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." ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index eaac1df7a..aecf57544 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -87,16 +87,16 @@ class CableTermination(models.Model): object_id_field='termination_b_id' ) - is_path_endpoint = True - class Meta: abstract = True def trace(self): """ - Return two items: the traceable portion of a cable path, and the termination points where it splits (if any). - This occurs when the trace is initiated from a midpoint 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. + Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and + the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint + 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 three-tuple: @@ -116,26 +116,35 @@ class CableTermination(models.Model): # Map a front port to its corresponding rear port 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 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 # Map a rear port/position to its corresponding front port elif isinstance(termination, RearPort): + if termination.positions > 1: + # Can't map to a FrontPort without a position if there are multiple options + if not position_stack: + raise CableTraceSplit(termination) - # Can't map to a FrontPort without a position if there are multiple options - if termination.positions > 1 and not position_stack: - raise CableTraceSplit(termination) + front_port = position_stack.pop() + position = front_port.rear_port_position - # We can assume position 1 if the RearPort has only one position - position = position_stack.pop() if position_stack else 1 - - # Validate the position - if position not in range(1, termination.positions + 1): - raise Exception("Invalid position for {} ({} positions): {})".format( - termination, termination.positions, position - )) + # Validate the position + if position not in range(1, termination.positions + 1): + raise Exception("Invalid position for {} ({} positions): {})".format( + 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: peer_port = FrontPort.objects.get( @@ -166,12 +175,12 @@ class CableTermination(models.Model): if not endpoint.cable: path.append((endpoint, None, None)) logger.debug("No cable connected") - return path, None + return path, None, position_stack # Check for loops if endpoint.cable in [segment[1] for segment in path]: logger.debug("Loop detected!") - return path, None + return path, None, position_stack # Record the current segment in the path far_end = endpoint.get_cable_peer() @@ -184,10 +193,10 @@ class CableTermination(models.Model): try: endpoint = get_peer_port(far_end) except CableTraceSplit as e: - return path, e.termination.frontports.all() + return path, e.termination.frontports.all(), position_stack if endpoint is None: - return path, None + return path, None, position_stack def get_cable_peer(self): if self.cable is None: @@ -204,7 +213,7 @@ class CableTermination(models.Model): endpoints = [] # 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] if split_ends is not None: for termination in split_ends: @@ -869,7 +878,6 @@ class FrontPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - is_path_endpoint = False class Meta: ordering = ('device', '_name') @@ -940,7 +948,6 @@ class RearPort(CableTermination, ComponentModel): tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] - is_path_endpoint = False class Meta: ordering = ('device', '_name') diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index aab82e502..172c366b5 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, Device, VirtualChassis +from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis @receiver(post_save, sender=VirtualChassis) @@ -51,7 +51,7 @@ def update_connected_endpoints(instance, **kwargs): # Update any endpoints for this Cable. endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() for endpoint in endpoints: - path, split_ends = endpoint.trace() + path, split_ends, position_stack = endpoint.trace() # Determine overall path status (connected or planned) path_status = True for segment in path: @@ -60,9 +60,11 @@ def update_connected_endpoints(instance, **kwargs): break 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)) endpoint_a.connected_endpoint = endpoint_b endpoint_a.connection_status = path_status diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 6db938732..c55d099c9 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -363,6 +363,7 @@ class CableTestCase(TestCase): ) self.interface1 = Interface.objects.create(device=self.device1, 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.save() @@ -370,10 +371,27 @@ class CableTestCase(TestCase): self.patch_pannel = Device.objects.create( 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.front_port = FrontPort.objects.create( - device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port + self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c') + self.front_port1 = FrontPort.objects.create( + 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): """ @@ -405,7 +423,7 @@ class CableTestCase(TestCase): cable = Cable.objects.filter(pk=self.cable.pk).first() 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 """ @@ -426,7 +444,7 @@ class CableTestCase(TestCase): """ 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): cable.clean() @@ -439,7 +457,94 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): 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 """ @@ -448,7 +553,7 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): 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 """ @@ -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 3', 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) - 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) FrontPort.objects.bulk_create(( 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), )) + # 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): """ 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_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable.full_clean() cable.save() # Retrieve endpoints @@ -551,22 +666,25 @@ class CablePathTestCase(TestCase): 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 - [Device 1] ----- [Panel 1] ----- [Device 2] + [Device 1] ----- [Panel 5] ----- [Device 2] Iface1 FP1 RP1 Iface1 """ - # Create cables + # Create cables (FP first, RP 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') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( - termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1') + termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 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() # Retrieve endpoints @@ -592,6 +710,97 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.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): """ 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_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') ) + cable1.full_clean() cable1.save() cable2 = Cable( 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') ) + cable2.full_clean() cable2.save() cable3 = Cable( 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') ) + cable3.full_clean() cable3.save() cable4 = Cable( 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') ) + cable4.full_clean() cable4.save() cable5 = Cable( 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') ) + cable5.full_clean() cable5.save() # Retrieve endpoints @@ -693,43 +907,51 @@ class CablePathTestCase(TestCase): 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_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') ) + cable2.full_clean() cable2.save() cable3 = Cable( 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') ) + cable3.full_clean() cable3.save() cable4 = Cable( 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') ) + cable4.full_clean() cable4.save() cable5 = Cable( 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') ) + cable5.full_clean() cable5.save() cable6 = Cable( 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') ) + cable6.full_clean() cable6.save() cable7 = Cable( 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') ) + cable7.full_clean() cable7.save() cable8 = Cable( 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') ) + cable8.full_clean() cable8.save() # Retrieve endpoints @@ -789,38 +1011,45 @@ class CablePathTestCase(TestCase): 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_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() cable3 = Cable( 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') ) + cable3.full_clean() cable3.save() cable4 = Cable( 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') ) + cable4.full_clean() cable4.save() cable5 = Cable( 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') ) + cable5.full_clean() cable5.save() cable6 = Cable( 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') ) + cable6.full_clean() cable6.save() cable7 = Cable( 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') ) + cable7.full_clean() cable7.save() # Retrieve endpoints @@ -870,11 +1099,13 @@ class CablePathTestCase(TestCase): termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), termination_b=CircuitTermination.objects.get(term_side='A') ) + cable1.full_clean() cable1.save() cable2 = Cable( termination_a=CircuitTermination.objects.get(term_side='Z'), termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') ) + cable2.full_clean() cable2.save() # Retrieve endpoints @@ -903,30 +1134,34 @@ class CablePathTestCase(TestCase): def test_connection_via_patched_circuit(self): """ 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 """ # Create cables 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') + termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1') ) + cable1.full_clean() cable1.save() 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') ) + cable2.full_clean() cable2.save() cable3 = Cable( 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() 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') ) + cable4.full_clean() cable4.save() # Retrieve endpoints diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 101c3ea6e..0c373cb24 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1915,7 +1915,7 @@ class CableTraceView(ObjectView): def get(self, request, pk): obj = get_object_or_404(self.queryset, pk=pk) - path, split_ends = obj.trace() + path, split_ends, position_stack = obj.trace() total_length = sum( [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] ) @@ -1924,6 +1924,7 @@ class CableTraceView(ObjectView): 'obj': obj, 'trace': path, 'split_ends': split_ends, + 'position_stack': position_stack, 'total_length': total_length, }) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 9c9c329e3..48da46525 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -6,6 +6,7 @@ from django import get_version from django.apps import apps from django.conf import settings from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -52,6 +53,7 @@ class Command(BaseCommand): pass # Additional objects to include + namespace['ContentType'] = ContentType namespace['User'] = User # Load convenience commands diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e41aa89e3..82e955b8a 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -167,7 +167,7 @@ class ChoiceVar(ScriptVariable): class ObjectVar(ScriptVariable): """ - NetBox object representation. The provided QuerySet will determine the choices available. + A single object within NetBox. """ form_field = DynamicModelChoiceField diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index eefac2c41..e753dd637 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -208,6 +208,10 @@ PLUGINS = [] # prefer IPv4 instead. 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_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bce482214..0c0f4506d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -99,6 +99,8 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) 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_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 1bba7cd67..9a7ad35ab 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -183,13 +183,6 @@ nav ul.pagination { margin-bottom: 8px !important; } -/* Racks */ -div.rack_header { - margin-left: 32px; - text-align: center; - width: 220px; -} - /* Devices */ table.component-list td.subtable { padding: 0; diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 1e7210e9a..df484609a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -88,6 +88,16 @@ + {% elif position_stack %} +
+

+ {% with last_position=position_stack|last %} + Trace completed, but there is no Front Port corresponding to + {{ last_position.device }} {{ last_position }}.
+ Therefore no end-to-end connection can be established. + {% endwith %} +

+
{% else %}

Trace completed!

diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index db5a134c6..a13a3900e 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,4 +1,6 @@ - +
+ +
-
-
-

Front

-
+
+

Front

{% include 'dcim/inc/rack_elevation.html' with face='front' %}
-
-
-

Rear

-
+
+

Rear

{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index fe9177f87..c0baef070 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -19,21 +19,21 @@ {% endif %} -
- {% for k, v_list in request.GET.lists %} - {% if k != 'per_page' %} - {% for v in v_list %} - - {% endfor %} - {% endif %} - {% endfor %} - per page -
{% endif %} +
+ {% for k, v_list in request.GET.lists %} + {% if k != 'per_page' %} + {% for v in v_list %} + + {% endfor %} + {% endif %} + {% endfor %} + per page +
{% if page %}
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }} diff --git a/netbox/utilities/metadata.py b/netbox/utilities/metadata.py new file mode 100644 index 000000000..8fd664d5a --- /dev/null +++ b/netbox/utilities/metadata.py @@ -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