diff --git a/.travis.yml b/.travis.yml index 33abc8425..13c6d406b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ addons: postgresql: "9.4" language: python python: - - "2.7" - "3.5" install: - pip install -r requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 75dfe2dab..14670a183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,82 @@ +v2.5.0 (FUTURE) + +## Notes + +### Python 3 Required + +As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://netbox.readthedocs.io/en/stable/installation/migrating-to-python3/) for assistance with upgrading. + +### Removed Deprecated User Activity Log + +The UserAction model, which was deprecated by the new change logging feature in NetBox v2.4, has been removed. If you need to archive legacy user activity, do so prior to upgrading to NetBox v2.5, as the database migration will remove all data associated with this model. + +### View Permissions in Django 2.1 + +Django 2.1 introduces view permissions for object types (not to be confused with object-level permissions). Implementation of [#323](https://github.com/digitalocean/netbox/issues/323) is planned for NetBox v2.6. Users are encourage to begin assigning view permissions as desired in preparation for their eventual enforcement. + +### upgrade.sh No Longer Invokes sudo + +The `upgrade.sh` script has been tweaked so that it no longer invokes `sudo` internally. This was done to ensure compatibility when running NetBox inside a Python virtual environment. If you need elevated permissions when upgrading NetBox, call the upgrade script with `sudo upgrade.sh`. + +## New Features + +### Patch Panels and Cables ([#20](https://github.com/digitalocean/netbox/issues/20)) + +NetBox now supports modeling physical cables for console, power, and interface connections. The new pass-through port component type has also been introduced to model patch panels and similar devices. + +## Enhancements + +* [#450](https://github.com/digitalocean/netbox/issues/450) - Added `outer_width` and `outer_depth` fields to rack model +* [#867](https://github.com/digitalocean/netbox/issues/867) - Added `description` field to circuit terminations +* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added an `asset_tag` field for racks +* [#1931](https://github.com/digitalocean/netbox/issues/1931) - Added a count of assigned IP addresses to the interface API serializer +* [#2000](https://github.com/digitalocean/netbox/issues/2000) - Dropped support for Python 2 +* [#2053](https://github.com/digitalocean/netbox/issues/2053) - Introduced the `LOGIN_TIMEOUT` configuration setting +* [#2057](https://github.com/digitalocean/netbox/issues/2057) - Added description columns to interface connections list +* [#2104](https://github.com/digitalocean/netbox/issues/2104) - Added a `status` field for racks +* [#2165](https://github.com/digitalocean/netbox/issues/2165) - Improved natural ordering of Interfaces +* [#2292](https://github.com/digitalocean/netbox/issues/2292) - Removed the deprecated UserAction model +* [#2367](https://github.com/digitalocean/netbox/issues/2367) - Removed deprecated RPCClient functionality +* [#2426](https://github.com/digitalocean/netbox/issues/2426) - Introduced `SESSION_FILE_PATH` configuration setting for authentication without write access to database +* [#2594](https://github.com/digitalocean/netbox/issues/2594) - `upgrade.sh` no longer invokes sudo + +## Changes From v2.5-beta2 + +* [#2474](https://github.com/digitalocean/netbox/issues/2474) - Add `cabled` and `connection_status` filters for device components +* [#2616](https://github.com/digitalocean/netbox/issues/2616) - Convert Rack `outer_unit` and Cable `length_unit` to integer-based choice fields +* [#2622](https://github.com/digitalocean/netbox/issues/2622) - Enable filtering cables by multiple types/colors +* [#2624](https://github.com/digitalocean/netbox/issues/2624) - Delete associated content type and permissions when removing InterfaceConnection model +* [#2626](https://github.com/digitalocean/netbox/issues/2626) - Remove extraneous permissions generated from proxy models +* [#2632](https://github.com/digitalocean/netbox/issues/2632) - Change representation of null values from `0` to `null` +* [#2639](https://github.com/digitalocean/netbox/issues/2639) - Fix preservation of length/dimensions unit for racks and cables +* [#2648](https://github.com/digitalocean/netbox/issues/2648) - Include the `connection_status` field in nested represenations of connectable device components +* [#2649](https://github.com/digitalocean/netbox/issues/2649) - Add `connected_endpoint_type` to connectable device component API representations + +## API Changes + +* The `/extras/recent-activity/` endpoint (replaced by change logging in v2.4) has been removed +* The `rpc_client` field has been removed from dcim.Platform (see #2367) +* Introduced a new API endpoint for cables at `/dcim/cables/` +* New endpoints for front and rear pass-through ports (and their templates) in parallel with existing device components +* The fields `interface_connection` on Interface and `interface` on CircuitTermination have been replaced with `connected_endpoint` and `connection_status` +* A new `cable` field has been added to console, power, and interface components and to circuit terminations +* New fields for dcim.Rack: `status`, `asset_tag`, `outer_width`, `outer_depth`, `outer_unit` +* The following boolean filters on dcim.Device and dcim.DeviceType have been renamed: + * `is_console_server`: `console_server_ports` + * `is_pdu`: `power_outlets` + * `is_network_device`: `interfaces` +* The following new boolean filters have been introduced for dcim.Device and dcim.DeviceType: + * `console_ports` + * `power_ports` + * `pass_through_ports` +* The field `interface_ordering` has been removed from the DeviceType serializer +* Added a `description` field to the CircuitTermination serializer +* Added `ipaddress_count` to InterfaceSerializer to show the count of assigned IP addresses for each interface +* The `available-prefixes` and `available-ips` IPAM endpoints now return an HTTP 204 response instead of HTTP 400 when no new objects can be created +* Filtering on null values now uses the string `null` instead of zero + +--- + v2.4.9 (2018-12-07) ## Enhancements diff --git a/README.md b/README.md index 5b090048d..04e61029a 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode ### Build Status -NetBox is built against both Python 2.7 and 3.5. Python 3.5 or higher is strongly recommended. - | | status | |-------------|------------| | **master** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) | diff --git a/base_requirements.txt b/base_requirements.txt index 6012ffa6c..3d1578400 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,25 +1,72 @@ -# django-filter-1.1.0 breaks with Django-2.1 -Django>=1.11,<2.1 +# The Python web framework on which NetBox is built +# https://github.com/django/django +Django + +# Django middleware which permits cross-domain API requests +# https://github.com/OttoYiu/django-cors-headers django-cors-headers + +# Runtime UI tool for debugging Django +# https://github.com/jazzband/django-debug-toolbar django-debug-toolbar -# django-filter-2.0.0 drops Python 2 support (blocked by #2000) -django-filter==1.1.0 + +# Library for writing reusable URL query filters +# https://github.com/carltongibson/django-filter +django-filter + +# Modified Preorder Tree Traversal (recursive nesting of objects) +# https://github.com/django-mptt/django-mptt django-mptt + +# Abstraction models for rendering and paginating HTML tables +# https://github.com/jieter/django-tables2 django-tables2 + +# User-defined tags for objects +# https://github.com/alex/django-taggit django-taggit + +# A Django REST Framework serializer which represents tags +# https://github.com/glemmaPaul/django-taggit-serializer django-taggit-serializer + +# A Django field for representing time zones +# https://github.com/mfogel/django-timezone-field/ django-timezone-field -# https://github.com/encode/django-rest-framework/issues/6053 -djangorestframework==3.8.1 + +# A REST API framework for Django projects +# https://github.com/encode/django-rest-framework +djangorestframework + +# Swagger/OpenAPI schema generation for REST APIs +# https://github.com/axnsan12/drf-yasg drf-yasg[validation] + +# Python interface to the graphviz graph rendering utility +# https://github.com/xflr6/graphviz graphviz -Markdown -natsort -ncclient + +# Simple markup language for rendering HTML +# https://github.com/Python-Markdown/markdown +# py-gfm requires Markdown<3.0 +Markdown<3.0 + +# Library for manipulating IP prefixes and addresses +# https://github.com/drkjam/netaddr netaddr -paramiko + +# Fork of PIL (Python Imaging Library) for image processing +# https://github.com/python-pillow/Pillow Pillow + +# PostgreSQL database adapter for Python +# https://github.com/psycopg/psycopg2 psycopg2-binary + +# GitHub-flavored Markdown extensions +# https://github.com/zopieux/py-gfm py-gfm + +# Extensive cryptographic library (fork of pycrypto) +# https://github.com/Legrandin/pycryptodome pycryptodome -xmltodict diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 5afd7876d..2ebea5ce5 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -9,7 +9,7 @@ This will launch a customized version of [the built-in Django shell](https://doc ``` $ ./manage.py nbshell ### NetBox interactive shell (jstretch-laptop) -### Python 2.7.6 | Django 1.11.3 | NetBox 2.1.0-dev +### Python 3.5.2 | Django 2.0.8 | NetBox 2.4.3 ### lsmodels() will show available models. Use help() for more info. ``` diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index b4de6fe7b..82412cdf7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -133,6 +133,14 @@ Setting this to True will permit only authenticated users to access any part of --- +## LOGIN_TIMEOUT + +Default: 1209600 seconds (14 days) + +The liftetime (in seconds) of the authentication cookie issued to a NetBox user upon login. + +--- + ## MAINTENANCE_MODE Default: False @@ -223,6 +231,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## SESSION_FILE_PATH + +Default: None + +Session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in the PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the user as which NetBox runs must have read and write permissions to this path. + +--- + ## TIME_ZONE Default: UTC diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index e56c9d8c6..f41c94ec6 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -25,7 +25,7 @@ Circuit types are fully customizable. A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is tied to a site, and optionally to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. +Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or pass-through port. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details. !!! note A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 5ae599c73..e51bf541c 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -4,12 +4,6 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type at the time of creation. (However, changes made to a device type will **not** apply to instances of that device type retroactively.) -The device type model includes three flags which inform what type of components may be added to it: - -* `is_console_server`: This device type has console server ports -* `is_pdu`: This device type has power outlets -* `is_network_device`: This device type has network interfaces - Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) @@ -32,6 +26,8 @@ Each device type is assigned a number of component templates which define the ph * Power ports * Power outlets * Network interfaces +* Front ports +* Rear ports * Device bays (which house child devices) Whenever a new device is created, its components are automatically created per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates defined: @@ -56,32 +52,28 @@ When assigning a multi-U device to a rack, it is considered to be mounted in the A device is said to be full depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow. -## Device Roles +## Device Components -Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches. - ---- - -# Device Components - -There are six types of device components which comprise all of the interconnection logic with NetBox: +There are eight types of device components which comprise all of the interconnection logic with NetBox: * Console ports * Console server ports * Power ports * Power outlets * Network interfaces +* Front ports +* Rear ports * Device bays -## Console +### Console Console ports connect only to console server ports. Console connections can be marked as either *planned* or *connected*. -## Power +### Power Power ports connect only to power outlets. Power connections can be marked as either *planned* or *connected*. -## Interfaces +### Interfaces Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*. @@ -91,10 +83,20 @@ Each interface can also be enabled or disabled, and optionally designated as man VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) -## Device Bays +### Pass-through Ports + +Pass-through ports are used to model physical terminations which comprise part of a longer path, such as a cable terminated to a patch panel. Each front port maps to a position on a rear port. A 24-port UTP patch panel, for instance, would have 24 front ports and 24 rear ports. Although this relationship is typically one-to-one, a rear port may have multiple front ports mapped to it. This can be useful for modeling instances where multiple paths share a common cable (for example, six different fiber connections sharing a 12-strand MPO cable). + +Pass-through ports can also be used to model "bump in the wire" devices, such as a media convertor or passive tap. + +### Device Bays Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. +## Device Roles + +Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches. + --- # Platforms @@ -118,3 +120,25 @@ Inventory items represent hardware components installed within a device, such as A virtual chassis represents a set of devices which share a single control plane: a stack of switches which are managed as a single device, for example. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management. It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently. + +--- + +# Cables + +A cable represents a physical connection between two termination points, such as between a console port and a patch panel port, or between two network interfaces. Cables can be traced through pass-through ports to form a complete path between two endpoints. In the example below, three individual cables comprise a path between the two connected endpoints. + +``` +|<------------------------------------------ Cable Path ------------------------------------------->| + + Device A Patch Panel A Patch Panel B Device B ++-----------+ +-------------+ +-------------+ +-----------+ +| Interface | --- Cable --- | Front Port | | Front Port | --- Cable --- | Interface | ++-----------+ +-------------+ +-------------+ +-----------+ + +-------------+ +-------------+ + | Rear Port | --- Cable --- | Rear Port | + +-------------+ +-------------+ +``` + +All connections between device components in NetBox are represented using cables. However, defining the actual cable plant is optional: Components can be be directly connected using cables with no type or other attributes assigned. + +Cables are also used to associated ports and interfaces with circuit terminations. To do this, first create the circuit termination, then navigate the desired component and connect a cable between the two. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index bca60ca89..6dc8a3c7a 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -64,13 +64,6 @@ Once the new code is in place, run the upgrade script (which may need to be run # ./upgrade.sh ``` -!!! warning - The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. Note that Python 2 will no longer be supported in NetBox v2.5. - -```no-highlight -# ./upgrade.sh -2 -``` - This script: * Installs or upgrades any new required Python packages diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py new file mode 100644 index 000000000..211dc4007 --- /dev/null +++ b/netbox/circuits/api/nested_serializers.py @@ -0,0 +1,52 @@ +from rest_framework import serializers + +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedCircuitSerializer', + 'NestedCircuitTerminationSerializer', + 'NestedCircuitTypeSerializer', + 'NestedProviderSerializer', +] + + +# +# Providers +# + +class NestedProviderSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + + class Meta: + model = Provider + fields = ['id', 'url', 'name', 'slug'] + + +# +# Circuits +# + +class NestedCircuitTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + + class Meta: + model = CircuitType + fields = ['id', 'url', 'name', 'slug'] + + +class NestedCircuitSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') + + class Meta: + model = Circuit + fields = ['id', 'url', 'cid'] + + +class NestedCircuitTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + circuit = NestedCircuitSerializer() + + class Meta: + model = CircuitTermination + fields = ['id', 'url', 'circuit', 'term_side'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index c19ab2fce..e94875c21 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,14 +1,13 @@ -from __future__ import unicode_literals - -from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer +from circuits.models import Provider, Circuit, CircuitTermination, CircuitType +from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer +from dcim.api.serializers import ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer -from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer +from .nested_serializers import * # @@ -26,16 +25,8 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): ] -class NestedProviderSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') - - class Meta: - model = Provider - fields = ['id', 'url', 'name', 'slug'] - - # -# Circuit types +# Circuits # class CircuitTypeSerializer(ValidatedModelSerializer): @@ -45,18 +36,6 @@ class CircuitTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedCircuitTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - - class Meta: - model = CircuitType - fields = ['id', 'url', 'name', 'slug'] - - -# -# Circuits -# - class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceField(choices=CIRCUIT_STATUS_CHOICES, required=False) @@ -72,25 +51,14 @@ class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): ] -class NestedCircuitSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - - class Meta: - model = Circuit - fields = ['id', 'url', 'cid'] - - -# -# Circuit Terminations -# - -class CircuitTerminationSerializer(ValidatedModelSerializer): +class CircuitTerminationSerializer(ConnectedEndpointSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = NestedInterfaceSerializer(required=False, allow_null=True) + cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 3fb4eda0a..b9d1b439b 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = CircuitsRootView # Field choices -router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice') # Providers router.register(r'providers', views.ProviderViewSet) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index eccc1edfc..877d85f85 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -31,7 +29,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.prefetch_related('tags') serializer_class = serializers.ProviderSerializer - filter_class = filters.ProviderFilter + filterset_class = filters.ProviderFilter @action(detail=True) def graphs(self, request, pk=None): @@ -51,7 +49,7 @@ class ProviderViewSet(CustomFieldModelViewSet): class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer - filter_class = filters.CircuitTypeFilter + filterset_class = filters.CircuitTypeFilter # @@ -61,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags') serializer_class = serializers.CircuitSerializer - filter_class = filters.CircuitFilter + filterset_class = filters.CircuitFilter # @@ -69,6 +67,8 @@ class CircuitViewSet(CustomFieldModelViewSet): # class CircuitTerminationViewSet(ModelViewSet): - queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') + queryset = CircuitTermination.objects.select_related( + 'circuit', 'site', 'connected_endpoint__device', 'cable' + ) serializer_class = serializers.CircuitTerminationSerializer - filter_class = filters.CircuitTerminationFilter + filterset_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index 613c347f2..bc0b7d87d 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py index c13975b06..03a981ea1 100644 --- a/netbox/circuits/constants.py +++ b/netbox/circuits/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Circuit statuses CIRCUIT_STATUS_DEPROVISIONING = 0 diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index a159fad42..0982624d5 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.db.models import Q @@ -12,18 +10,21 @@ from .models import Provider, Circuit, CircuitTermination, CircuitType class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='circuits__terminations__site', + field_name='circuits__terminations__site', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='circuits__terminations__site__slug', + field_name='circuits__terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -54,7 +55,10 @@ class CircuitTypeFilter(django_filters.FilterSet): class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -64,7 +68,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Provider (ID)', ) provider = django_filters.ModelMultipleChoiceFilter( - name='provider__slug', + field_name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', label='Provider (slug)', @@ -74,7 +78,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Circuit type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( - name='type__slug', + field_name='type__slug', queryset=CircuitType.objects.all(), to_field_name='slug', label='Circuit type (slug)', @@ -88,18 +92,18 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='terminations__site', + field_name='terminations__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='terminations__site__slug', + field_name='terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -117,6 +121,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(cid__icontains=value) | Q(terminations__xconnect_id__icontains=value) | Q(terminations__pp_info__icontains=value) | + Q(terminations__description__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() @@ -136,7 +141,7 @@ class CircuitTerminationFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -152,5 +157,6 @@ class CircuitTerminationFilter(django_filters.FilterSet): return queryset.filter( Q(circuit__cid__icontains=value) | Q(xconnect_id__icontains=value) | - Q(pp_info__icontains=value) + Q(pp_info__icontains=value) | + Q(description__icontains=value) ).distinct() diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index aae8bb5f6..0c31e78d6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,16 +1,14 @@ -from __future__ import unicode_literals - from django import forms from django.db.models import Count from taggit.forms import TagField -from dcim.models import Site, Device, Interface, Rack +from dcim.models import Site from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, - ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, FilterChoiceField, + SmallTextarea, SlugField, ) from .constants import CIRCUIT_STATUS_CHOICES from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -23,14 +21,22 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags'] + fields = [ + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + ] widgets = { - 'noc_contact': SmallTextarea(attrs={'rows': 5}), - 'admin_contact': SmallTextarea(attrs={'rows': 5}), + 'noc_contact': SmallTextarea( + attrs={'rows': 5} + ), + 'admin_contact': SmallTextarea( + attrs={'rows': 5} + ), } help_texts = { 'name': "Full name of the provider", @@ -56,23 +62,57 @@ class ProviderCSVForm(forms.ModelForm): class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) - asn = forms.IntegerField(required=False, label='ASN') - account = forms.CharField(max_length=30, required=False, label='Account number') - portal_url = forms.URLField(required=False, label='Portal') - noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact') - admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact') - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Provider.objects.all(), + widget=forms.MultipleHiddenInput + ) + asn = forms.IntegerField( + required=False, + label='ASN' + ) + account = forms.CharField( + max_length=30, + required=False, + label='Account number' + ) + portal_url = forms.URLField( + required=False, + label='Portal' + ) + noc_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='NOC contact' + ) + admin_contact = forms.CharField( + required=False, + widget=SmallTextarea, + label='Admin contact' + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + nullable_fields = [ + 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ] class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug') - asn = forms.IntegerField(required=False, label='ASN') + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug' + ) + asn = forms.IntegerField( + required=False, + label='ASN' + ) # @@ -84,7 +124,9 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class CircuitTypeCSVForm(forms.ModelForm): @@ -104,7 +146,9 @@ class CircuitTypeCSVForm(forms.ModelForm): class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Circuit @@ -159,28 +203,61 @@ class CircuitCSVForm(forms.ModelForm): class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) - type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) - provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') - description = forms.CharField(max_length=100, required=False) - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Circuit.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = forms.ModelChoiceField( + queryset=CircuitType.objects.all(), + required=False + ) + provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), + required=False, + initial='' + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + commit_rate = forms.IntegerField( + required=False, + label='Commit rate (Kbps)' + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea + ) class Meta: - nullable_fields = ['tenant', 'commit_rate', 'description', 'comments'] + nullable_fields = [ + 'tenant', 'commit_rate', 'description', 'comments', + ] class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) type = FilterChoiceField( - queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), + queryset=CircuitType.objects.annotate( + filter_count=Count('circuits') + ), to_field_name='slug' ) provider = FilterChoiceField( - queryset=Provider.objects.annotate(filter_count=Count('circuits')), + queryset=Provider.objects.annotate( + filter_count=Count('circuits') + ), to_field_name='slug' ) status = AnnotatedMultipleChoiceField( @@ -190,74 +267,35 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('circuits')), + queryset=Tenant.objects.annotate( + filter_count=Count('circuits') + ), to_field_name='slug', null_label='-- None --' ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), + queryset=Site.objects.annotate( + filter_count=Count('circuit_terminations') + ), to_field_name='slug' ) - commit_rate = forms.IntegerField(required=False, min_value=0, label='Commit rate (Kbps)') + commit_rate = forms.IntegerField( + required=False, + min_value=0, + label='Commit rate (Kbps)' + ) # # Circuit terminations # -class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='Rack', - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) - ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - required=False, - label='Device', - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'interface'} - ) - ) - interface = ChainedModelChoiceField( - queryset=Interface.objects.connectable().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ), - chains=( - ('device', 'device'), - ), - required=False, - label='Interface', - widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical', - disabled_indicator='is_connected' - ) - ) +class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitTermination fields = [ - 'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', + 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -267,25 +305,3 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm widgets = { 'term_side': forms.HiddenInput(), } - - def __init__(self, *args, **kwargs): - - # Initialize helper selectors - instance = kwargs.get('instance') - if instance and instance.interface is not None: - initial = kwargs.get('initial', {}).copy() - initial['rack'] = instance.interface.device.rack - initial['device'] = instance.interface.device - kwargs['initial'] = initial - - super(CircuitTerminationForm, self).__init__(*args, **kwargs) - - # Mark connected interfaces as disabled - self.fields['interface'].choices = [] - for iface in self.fields['interface'].queryset: - self.fields['interface'].choices.append( - (iface.id, { - 'label': iface.name, - 'disabled': iface.is_connected and iface.pk != self.initial.get('interface'), - }) - ) diff --git a/netbox/circuits/migrations/0001_initial.py b/netbox/circuits/migrations/0001_initial.py index 470fbee46..dd4dc612b 100644 --- a/netbox/circuits/migrations/0001_initial.py +++ b/netbox/circuits/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py index 1ae1c5d45..3fcec7933 100644 --- a/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py +++ b/netbox/circuits/migrations/0001_initial_squashed_0010_circuit_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:25 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0002_auto_20160622_1821.py b/netbox/circuits/migrations/0002_auto_20160622_1821.py index 32f31b376..2d350b5f3 100644 --- a/netbox/circuits/migrations/0002_auto_20160622_1821.py +++ b/netbox/circuits/migrations/0002_auto_20160622_1821.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0003_provider_32bit_asn_support.py b/netbox/circuits/migrations/0003_provider_32bit_asn_support.py index f1010064e..e1e9adab9 100644 --- a/netbox/circuits/migrations/0003_provider_32bit_asn_support.py +++ b/netbox/circuits/migrations/0003_provider_32bit_asn_support.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-13 19:24 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations diff --git a/netbox/circuits/migrations/0004_circuit_add_tenant.py b/netbox/circuits/migrations/0004_circuit_add_tenant.py index 641b13afd..de81f21eb 100644 --- a/netbox/circuits/migrations/0004_circuit_add_tenant.py +++ b/netbox/circuits/migrations/0004_circuit_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:59 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py index f309cb2d8..51b09ad4c 100644 --- a/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py +++ b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-08 20:24 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0006_terminations.py b/netbox/circuits/migrations/0006_terminations.py index e5451498a..1a083c3da 100644 --- a/netbox/circuits/migrations/0006_terminations.py +++ b/netbox/circuits/migrations/0006_terminations.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-13 16:30 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0007_circuit_add_description.py b/netbox/circuits/migrations/0007_circuit_add_description.py index 023e5890a..238cb07dd 100644 --- a/netbox/circuits/migrations/0007_circuit_add_description.py +++ b/netbox/circuits/migrations/0007_circuit_add_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-17 20:08 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py index 14ee6686d..b7ccafd26 100644 --- a/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py +++ b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-19 17:17 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/circuits/migrations/0009_unicode_literals.py b/netbox/circuits/migrations/0009_unicode_literals.py index 0f22a2268..0cc58fea9 100644 --- a/netbox/circuits/migrations/0009_unicode_literals.py +++ b/netbox/circuits/migrations/0009_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations, models diff --git a/netbox/circuits/migrations/0010_circuit_status.py b/netbox/circuits/migrations/0010_circuit_status.py index 3abe5d319..675a0c1fb 100644 --- a/netbox/circuits/migrations/0010_circuit_status.py +++ b/netbox/circuits/migrations/0010_circuit_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-06 18:48 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0011_tags.py b/netbox/circuits/migrations/0011_tags.py index b3510f8f4..112436223 100644 --- a/netbox/circuits/migrations/0011_tags.py +++ b/netbox/circuits/migrations/0011_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/circuits/migrations/0012_change_logging.py b/netbox/circuits/migrations/0012_change_logging.py index db5057858..c9a3ee41d 100644 --- a/netbox/circuits/migrations/0012_change_logging.py +++ b/netbox/circuits/migrations/0012_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:14 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/circuits/migrations/0013_cables.py b/netbox/circuits/migrations/0013_cables.py new file mode 100644 index 000000000..4e9125a99 --- /dev/null +++ b/netbox/circuits/migrations/0013_cables.py @@ -0,0 +1,89 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + +from dcim.constants import CONNECTION_STATUS_CONNECTED + + +def circuit_terminations_to_cables(apps, schema_editor): + """ + Copy all existing CircuitTermination Interface associations as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + Interface = apps.get_model('dcim', 'Interface') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + circuittermination_type = ContentType.objects.get_for_model(CircuitTermination) + interface_type = ContentType.objects.get_for_model(Interface) + + # Create a new Cable instance from each console connection + if 'test' not in sys.argv: + print("\n Adding circuit terminations... ", end='', flush=True) + for circuittermination in CircuitTermination.objects.filter(interface__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=circuittermination_type, + termination_a_id=circuittermination.id, + termination_b_type=interface_type, + termination_b_id=circuittermination.interface_id, + status=CONNECTION_STATUS_CONNECTED + ) + + # Cache the Cable on its two termination points + CircuitTermination.objects.filter(pk=circuittermination.pk).update( + cable=cable, + connected_endpoint=circuittermination.interface, + connection_status=CONNECTION_STATUS_CONNECTED + ) + # Cache the connected Cable on the Interface + Interface.objects.filter(pk=circuittermination.interface_id).update( + cable=cable, + _connected_circuittermination=circuittermination, + connection_status=CONNECTION_STATUS_CONNECTED + ) + + cable_count = Cable.objects.filter(termination_a_type=circuittermination_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('circuits', '0012_change_logging'), + ('dcim', '0066_cables'), + ] + + operations = [ + + # Add new CircuitTermination fields + migrations.AddField( + model_name='circuittermination', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'), + ), + migrations.AddField( + model_name='circuittermination', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='circuittermination', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Copy CircuitTermination connections to Interfaces as Cables + migrations.RunPython(circuit_terminations_to_cables), + + # Remove interface field from CircuitTermination + migrations.RemoveField( + model_name='circuittermination', + name='interface', + ), + ] diff --git a/netbox/circuits/migrations/0014_circuittermination_description.py b/netbox/circuits/migrations/0014_circuittermination_description.py new file mode 100644 index 000000000..2b3070427 --- /dev/null +++ b/netbox/circuits/migrations/0014_circuittermination_description.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-05 18:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0013_cables'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 6a2e55afc..776b24156 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,20 +1,17 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager -from dcim.constants import STATUS_CLASSES +from dcim.constants import CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, STATUS_CLASSES from dcim.fields import ASNField +from dcim.models import CableTermination from extras.models import CustomFieldModel, ObjectChange from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES -@python_2_unicode_compatible class Provider(ChangeLoggedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -84,7 +81,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) -@python_2_unicode_compatible class CircuitType(ChangeLoggedModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -116,12 +112,11 @@ class CircuitType(ChangeLoggedModel): ) -@python_2_unicode_compatible class Circuit(ChangeLoggedModel, CustomFieldModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple - circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device - interface, but this is not required. Circuit port speed and commit rate are measured in Kbps. + circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured + in Kbps. """ cid = models.CharField( max_length=50, @@ -217,8 +212,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): return self._get_termination('Z') -@python_2_unicode_compatible -class CircuitTermination(models.Model): +class CircuitTermination(CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -234,13 +228,17 @@ class CircuitTermination(models.Model): on_delete=models.PROTECT, related_name='circuit_terminations' ) - interface = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.Interface', - on_delete=models.PROTECT, - related_name='circuit_termination', + on_delete=models.SET_NULL, + related_name='+', blank=True, null=True ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)' ) @@ -260,13 +258,17 @@ class CircuitTermination(models.Model): blank=True, verbose_name='Patch panel/port(s)' ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: ordering = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side'] def __str__(self): - return '{} (Side {})'.format(self.circuit, self.get_term_side_display()) + return 'Side {}'.format(self.get_term_side_display()) def log_change(self, user, request_id, action): """ diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 40a1e1031..bdfe8c0b6 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 6bf3114d9..c6a215db8 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor @@ -25,12 +23,6 @@ STATUS_LABEL = """ class CircuitTerminationColumn(tables.Column): def render(self, value): - if value.interface: - return mark_safe('{}'.format( - value.interface.device.get_absolute_url(), - value.site, - value.interface.device - )) return mark_safe('{}'.format( value.site.get_absolute_url(), value.site diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index bcaf2dee4..0810f0ff9 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -15,7 +13,7 @@ class ProviderTest(APITestCase): def setUp(self): - super(ProviderTest, self).setUp() + super().setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -137,7 +135,7 @@ class CircuitTypeTest(APITestCase): def setUp(self): - super(CircuitTypeTest, self).setUp() + super().setUp() self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') @@ -212,7 +210,7 @@ class CircuitTest(APITestCase): def setUp(self): - super(CircuitTest, self).setUp() + super().setUp() self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') @@ -328,46 +326,26 @@ class CircuitTerminationTest(APITestCase): def setUp(self): - super(CircuitTerminationTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') - manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True - ) - devicerole = DeviceRole.objects.create( - name='Test Device Role 1', slug='test-device-role-1', color='ff0000' - ) - device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site1 - ) - device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site2 - ) - self.interface1 = Interface.objects.create(device=device1, name='Test Interface 1') - self.interface2 = Interface.objects.create(device=device2, name='Test Interface 2') - self.interface3 = Interface.objects.create(device=device1, name='Test Interface 3') - self.interface4 = Interface.objects.create(device=device2, name='Test Interface 4') - self.interface5 = Interface.objects.create(device=device1, name='Test Interface 5') - self.interface6 = Interface.objects.create(device=device2, name='Test Interface 6') - provider = Provider.objects.create(name='Test Provider', slug='test-provider') circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) self.circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface1, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface2, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface3, port_speed=1000000 + circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) self.circuittermination4 = CircuitTermination.objects.create( - circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface4, port_speed=1000000 + circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000 ) def test_get_circuittermination(self): @@ -390,7 +368,6 @@ class CircuitTerminationTest(APITestCase): 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_A, 'site': self.site1.pk, - 'interface': self.interface5.pk, 'port_speed': 1000000, } @@ -403,20 +380,18 @@ class CircuitTerminationTest(APITestCase): self.assertEqual(circuittermination4.circuit_id, data['circuit']) self.assertEqual(circuittermination4.term_side, data['term_side']) self.assertEqual(circuittermination4.site_id, data['site']) - self.assertEqual(circuittermination4.interface_id, data['interface']) self.assertEqual(circuittermination4.port_speed, data['port_speed']) def test_update_circuittermination(self): circuittermination5 = CircuitTermination.objects.create( - circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface5, port_speed=1000000 + circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 ) data = { 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_Z, 'site': self.site2.pk, - 'interface': self.interface6.pk, 'port_speed': 1000000, } @@ -428,7 +403,6 @@ class CircuitTerminationTest(APITestCase): circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination1.term_side, data['term_side']) self.assertEqual(circuittermination1.site_id, data['site']) - self.assertEqual(circuittermination1.interface_id, data['interface']) self.assertEqual(circuittermination1.port_speed, data['port_speed']) def test_delete_circuittermination(self): diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 449da3964..be1106308 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals - from django.conf.urls import url +from dcim.views import CableCreateView, CableTraceView from extras.views import ObjectChangeLogView from . import views -from .models import Circuit, CircuitType, Provider +from .models import Circuit, CircuitTermination, CircuitType, Provider app_name = 'circuits' urlpatterns = [ @@ -44,5 +43,7 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + url(r'^circuit-terminations/(?P\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + url(r'^circuit-terminations/(?P\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e116e4556..661f78e8e 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin @@ -134,7 +132,7 @@ class CircuitListView(ObjectListView): queryset = Circuit.objects.select_related( 'provider', 'type', 'tenant' ).prefetch_related( - 'terminations__site', 'terminations__interface__device' + 'terminations__site' ) filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm @@ -148,12 +146,12 @@ class CircuitView(View): circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) termination_a = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' + 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_A ).first() termination_z = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' + 'site__region', 'connected_endpoint__device' ).filter( circuit=circuit, term_side=TERM_SIDE_Z ).first() diff --git a/netbox/dcim/api/exceptions.py b/netbox/dcim/api/exceptions.py index 8804da436..05ad86b5b 100644 --- a/netbox/dcim/api/exceptions.py +++ b/netbox/dcim/api/exceptions.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework.exceptions import APIException diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py new file mode 100644 index 000000000..4d7478595 --- /dev/null +++ b/netbox/dcim/api/nested_serializers.py @@ -0,0 +1,249 @@ +from rest_framework import serializers + +from dcim.constants import CONNECTION_STATUS_CHOICES +from dcim.models import ( + Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, + Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate, + Region, Site, VirtualChassis, +) +from utilities.api import ChoiceField, WritableNestedSerializer + +__all__ = [ + 'NestedCableSerializer', + 'NestedConsolePortSerializer', + 'NestedConsoleServerPortSerializer', + 'NestedDeviceBaySerializer', + 'NestedDeviceRoleSerializer', + 'NestedDeviceSerializer', + 'NestedDeviceTypeSerializer', + 'NestedFrontPortSerializer', + 'NestedFrontPortTemplateSerializer', + 'NestedInterfaceSerializer', + 'NestedManufacturerSerializer', + 'NestedPlatformSerializer', + 'NestedPowerOutletSerializer', + 'NestedPowerPortSerializer', + 'NestedRackGroupSerializer', + 'NestedRackRoleSerializer', + 'NestedRackSerializer', + 'NestedRearPortSerializer', + 'NestedRearPortTemplateSerializer', + 'NestedRegionSerializer', + 'NestedSiteSerializer', + 'NestedVirtualChassisSerializer', +] + + +# +# Regions/sites +# + +class NestedRegionSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + + class Meta: + model = Region + fields = ['id', 'url', 'name', 'slug'] + + +class NestedSiteSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') + + class Meta: + model = Site + fields = ['id', 'url', 'name', 'slug'] + + +# +# Racks +# + +class NestedRackGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') + + class Meta: + model = RackGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedRackRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + + class Meta: + model = RackRole + fields = ['id', 'url', 'name', 'slug'] + + +class NestedRackSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + + class Meta: + model = Rack + fields = ['id', 'url', 'name', 'display_name'] + + +# +# Device types +# + +class NestedManufacturerSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + + class Meta: + model = Manufacturer + fields = ['id', 'url', 'name', 'slug'] + + +class NestedDeviceTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = NestedManufacturerSerializer(read_only=True) + + class Meta: + model = DeviceType + fields = ['id', 'url', 'manufacturer', 'model', 'slug'] + + +class NestedRearPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') + + class Meta: + model = RearPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedFrontPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') + + class Meta: + model = FrontPortTemplate + fields = ['id', 'url', 'name'] + + +# +# Devices +# + +class NestedDeviceRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + + class Meta: + model = DeviceRole + fields = ['id', 'url', 'name', 'slug'] + + +class NestedPlatformSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') + + class Meta: + model = Platform + fields = ['id', 'url', 'name', 'slug'] + + +class NestedDeviceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + + class Meta: + model = Device + fields = ['id', 'url', 'name', 'display_name'] + + +class NestedConsoleServerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = ConsoleServerPort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedConsolePortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = ConsolePort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedPowerOutletSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = PowerOutlet + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedPowerPortSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') + device = NestedDeviceSerializer(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = PowerPort + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedInterfaceSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + class Meta: + model = Interface + fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] + + +class NestedRearPortSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'device', 'name', 'cable'] + + +class NestedFrontPortSerializer(WritableNestedSerializer): + device = NestedDeviceSerializer(read_only=True) + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') + + class Meta: + model = FrontPort + fields = ['id', 'url', 'device', 'name', 'cable'] + + +class NestedDeviceBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = DeviceBay + fields = ['id', 'url', 'device', 'name'] + + +# +# Cables +# + +class NestedCableSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = ['id', 'url', 'label'] + + +# +# Virtual chassis +# + +class NestedVirtualChassisSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') + master = NestedDeviceSerializer() + + class Meta: + model = VirtualChassis + fields = ['id', 'url', 'master'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 94d2b07a8..765ed83dd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,43 +1,58 @@ -from __future__ import unicode_literals - from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from circuits.models import Circuit, CircuitTermination -from dcim.constants import ( - CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, - RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, -) +from dcim.constants import * from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from extras.api.customfields import CustomFieldModelSerializer -from ipam.models import IPAddress, VLAN -from tenancy.api.serializers import NestedTenantSerializer -from users.api.serializers import NestedUserSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import VLAN +from tenancy.api.nested_serializers import NestedTenantSerializer +from users.api.nested_serializers import NestedUserSerializer from utilities.api import ( - ChoiceField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, - WritableNestedSerializer, + ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, + WritableNestedSerializer, get_serializer_for_model, ) -from virtualization.models import Cluster +from virtualization.api.nested_serializers import NestedClusterSerializer +from .nested_serializers import * + + +class ConnectedEndpointSerializer(ValidatedModelSerializer): + connected_endpoint_type = serializers.SerializerMethodField(read_only=True) + connected_endpoint = serializers.SerializerMethodField(read_only=True) + connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) + + def get_connected_endpoint_type(self, obj): + if hasattr(obj, 'connected_endpoint') and obj.connected_endpoint is not None: + return '{}.{}'.format( + obj.connected_endpoint._meta.app_label, + obj.connected_endpoint._meta.model_name + ) + return None + + def get_connected_endpoint(self, obj): + """ + Return the appropriate serializer for the type of connected object. + """ + if getattr(obj, 'connected_endpoint', None) is None: + return None + + serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested') + context = {'request': self.context['request']} + data = serializer(obj.connected_endpoint, context=context).data + + return data # -# Regions +# Regions/sites # -class NestedRegionSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') - - class Meta: - model = Region - fields = ['id', 'url', 'name', 'slug'] - - class RegionSerializer(serializers.ModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True) @@ -46,10 +61,6 @@ class RegionSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'parent'] -# -# Sites -# - class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=SITE_STATUS_CHOICES, required=False) region = NestedRegionSerializer(required=False, allow_null=True) @@ -72,16 +83,8 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): ] -class NestedSiteSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') - - class Meta: - model = Site - fields = ['id', 'url', 'name', 'slug'] - - # -# Rack groups +# Racks # class RackGroupSerializer(ValidatedModelSerializer): @@ -92,18 +95,6 @@ class RackGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class NestedRackGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') - - class Meta: - model = RackGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Rack roles -# - class RackRoleSerializer(ValidatedModelSerializer): class Meta: @@ -111,32 +102,23 @@ class RackRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color'] -class NestedRackRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - - class Meta: - model = RackRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Racks -# - class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer() group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) + status = ChoiceField(choices=RACK_STATUS_CHOICES, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True) width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) + outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False) tags = TagListSerializerField(required=False) class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', + 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This # prevents facility_id from being interpreted as a required field. @@ -153,31 +135,11 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): validator(data) # Enforce model validation - super(RackSerializer, self).validate(data) + super().validate(data) return data -class NestedRackSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - - class Meta: - model = Rack - fields = ['id', 'url', 'name', 'display_name'] - - -# -# Rack units -# - -class NestedDeviceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') - - class Meta: - model = Device - fields = ['id', 'url', 'name', 'display_name'] - - class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. @@ -188,10 +150,6 @@ class RackUnitSerializer(serializers.Serializer): device = NestedDeviceSerializer(read_only=True) -# -# Rack reservations -# - class RackReservationSerializer(ValidatedModelSerializer): rack = NestedRackSerializer() user = NestedUserSerializer() @@ -203,7 +161,7 @@ class RackReservationSerializer(ValidatedModelSerializer): # -# Manufacturers +# Device types # class ManufacturerSerializer(ValidatedModelSerializer): @@ -213,21 +171,8 @@ class ManufacturerSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedManufacturerSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - - class Meta: - model = Manufacturer - fields = ['id', 'url', 'name', 'slug'] - - -# -# Device types -# - class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True) tags = TagListSerializerField(required=False) @@ -235,25 +180,11 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'instance_count', + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count', ] -class NestedDeviceTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') - manufacturer = NestedManufacturerSerializer(read_only=True) - - class Meta: - model = DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug'] - - -# -# Console port templates -# - class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -262,10 +193,6 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'device_type', 'name'] -# -# Console server port templates -# - class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -274,10 +201,6 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'device_type', 'name'] -# -# Power port templates -# - class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -286,10 +209,6 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'device_type', 'name'] -# -# Power outlet templates -# - class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -298,10 +217,6 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'device_type', 'name'] -# -# Interface templates -# - class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) @@ -311,9 +226,24 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] -# -# Device bay templates -# +class RearPortTemplateSerializer(ValidatedModelSerializer): + device_type = NestedDeviceTypeSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + + class Meta: + model = RearPortTemplate + fields = ['id', 'device_type', 'name', 'type', 'positions'] + + +class FrontPortTemplateSerializer(ValidatedModelSerializer): + device_type = NestedDeviceTypeSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + rear_port = NestedRearPortTemplateSerializer() + + class Meta: + model = FrontPortTemplate + fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position'] + class DeviceBayTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() @@ -324,7 +254,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # -# Device roles +# Devices # class DeviceRoleSerializer(ValidatedModelSerializer): @@ -334,64 +264,12 @@ class DeviceRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'color', 'vm_role'] -class NestedDeviceRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') - - class Meta: - model = DeviceRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Platforms -# - class PlatformSerializer(ValidatedModelSerializer): manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) class Meta: model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] - - -class NestedPlatformSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - - class Meta: - model = Platform - fields = ['id', 'url', 'name', 'slug'] - - -# -# Devices -# - -# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency -class DeviceIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - -# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency -class NestedClusterSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') - - class Meta: - model = Cluster - fields = ['id', 'url', 'name'] - - -# Cannot import NestedVirtualChassisSerializer due to circular dependency -class DeviceVirtualChassisSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer() - - class Meta: - model = VirtualChassis - fields = ['id', 'url', 'master'] + fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -403,12 +281,12 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True) status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False) - primary_ip = DeviceIPAddressSerializer(read_only=True) - primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = DeviceIPAddressSerializer(required=False, allow_null=True) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) parent_device = serializers.SerializerMethodField() cluster = NestedClusterSerializer(required=False, allow_null=True) - virtual_chassis = DeviceVirtualChassisSerializer(required=False, allow_null=True) + virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) class Meta: @@ -416,8 +294,8 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'local_context_data', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -430,7 +308,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): validator(data) # Enforce model validation - super(DeviceSerializer, self).validate(data) + super().validate(data) return data @@ -452,203 +330,90 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): fields = [ 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', - 'config_context', 'created', 'last_updated', 'local_context_data', + 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'config_context', 'created', 'last_updated', ] def get_config_context(self, obj): return obj.get_config_context() -# -# Console server ports -# - -class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): +class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = ConsoleServerPort - fields = ['id', 'device', 'name', 'connected_console', 'tags'] - read_only_fields = ['connected_console'] - - -class NestedConsoleServerPortSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') - device = NestedDeviceSerializer(read_only=True) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ConsoleServerPort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return hasattr(obj, 'connected_console') and obj.connected_console is not None - - -# -# Console ports -# - -class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): - device = NestedDeviceSerializer() - cs_port = NestedConsoleServerPortSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) - - class Meta: - model = ConsolePort - fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags'] - - -class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') - device = NestedDeviceSerializer(read_only=True) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = ConsolePort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return obj.cs_port is not None - - -# -# Power outlets -# - -class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): - device = NestedDeviceSerializer() - tags = TagListSerializerField(required=False) - - class Meta: - model = PowerOutlet - fields = ['id', 'device', 'name', 'connected_port', 'tags'] - read_only_fields = ['connected_port'] - - -class NestedPowerOutletSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') - device = NestedDeviceSerializer(read_only=True) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = PowerOutlet - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return hasattr(obj, 'connected_port') and obj.connected_port is not None - - -# -# Power ports -# - -class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): - device = NestedDeviceSerializer() - power_outlet = NestedPowerOutletSerializer(required=False, allow_null=True) - tags = TagListSerializerField(required=False) - - class Meta: - model = PowerPort - fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags'] - - -class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') - device = NestedDeviceSerializer(read_only=True) - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = PowerPort - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - def get_is_connected(self, obj): - return obj.power_outlet is not None - - -# -# Interfaces -# - -class IsConnectedMixin(object): - """ - Provide a method for setting is_connected on Interface serializers. - """ - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit. - """ - if obj.connection: - return True - if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None: - return True - return False - - -class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') - is_connected = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = Interface - fields = ['id', 'url', 'device', 'name', 'is_connected'] - - -class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - - class Meta: - model = Circuit - fields = ['id', 'url', 'cid'] - - -class InterfaceCircuitTerminationSerializer(WritableNestedSerializer): - circuit = InterfaceNestedCircuitSerializer(read_only=True) - - class Meta: - model = CircuitTermination fields = [ - 'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', ] -# Cannot import ipam.api.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') +class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): + device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] + model = ConsolePort + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] -class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer): +class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): + device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) + + class Meta: + model = PowerOutlet + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] + + +class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): + device = NestedDeviceSerializer() + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) + + class Meta: + model = PowerPort + fields = [ + 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', + 'tags', + ] + + +class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) - is_connected = serializers.SerializerMethodField(read_only=True) - interface_connection = serializers.SerializerMethodField(read_only=True) - circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) - untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), - serializer=InterfaceVLANSerializer, + serializer=NestedVLANSerializer, required=False, many=True ) + cable = NestedCableSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans', - 'tags', + 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', + 'tagged_vlans', 'tags', 'count_ipaddresses', ] + # TODO: This validation should be handled by Interface.clean() def validate(self, data): # All associated VLANs be global or assigned to the parent device's site. @@ -666,21 +431,42 @@ class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSeri "be global.".format(vlan) }) - return super(InterfaceSerializer, self).validate(data) - - def get_interface_connection(self, obj): - if obj.connection: - context = { - 'request': self.context['request'], - 'interface': obj.connected_interface, - } - return ContextualInterfaceConnectionSerializer(obj.connection, context=context).data - return None + return super().validate(data) -# -# Device bays -# +class RearPortSerializer(ValidatedModelSerializer): + device = NestedDeviceSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) + + class Meta: + model = RearPort + fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags'] + + +class FrontPortRearPortSerializer(WritableNestedSerializer): + """ + NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device) + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + + class Meta: + model = RearPort + fields = ['id', 'url', 'name'] + + +class FrontPortSerializer(ValidatedModelSerializer): + device = NestedDeviceSerializer() + type = ChoiceField(choices=PORT_TYPE_CHOICES) + rear_port = FrontPortRearPortSerializer() + cable = NestedCableSerializer(read_only=True) + tags = TagListSerializerField(required=False) + + class Meta: + model = FrontPort + fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags'] + class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): device = NestedDeviceSerializer() @@ -692,15 +478,6 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): fields = ['id', 'device', 'name', 'installed_device', 'tags'] -class NestedDeviceBaySerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - device = NestedDeviceSerializer(read_only=True) - - class Meta: - model = DeviceBay - fields = ['id', 'url', 'device', 'name'] - - # # Inventory items # @@ -720,41 +497,76 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): ] +# +# Cables +# + +class CableSerializer(ValidatedModelSerializer): + termination_a_type = ContentTypeField() + termination_b_type = ContentTypeField() + termination_a = serializers.SerializerMethodField(read_only=True) + termination_b = serializers.SerializerMethodField(read_only=True) + status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) + length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False) + + class Meta: + model = Cable + fields = [ + 'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', + 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + def _get_termination(self, obj, side): + """ + Serialize a nested representation of a termination. + """ + if side.lower() not in ['a', 'b']: + raise ValueError("Termination side must be either A or B.") + termination = getattr(obj, 'termination_{}'.format(side.lower())) + if termination is None: + return None + serializer = get_serializer_for_model(termination, prefix='Nested') + context = {'request': self.context['request']} + data = serializer(termination, context=context).data + + return data + + def get_termination_a(self, obj): + return self._get_termination(obj, 'a') + + def get_termination_b(self, obj): + return self._get_termination(obj, 'b') + + +class TracedCableSerializer(serializers.ModelSerializer): + """ + Used only while tracing a cable path. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') + + class Meta: + model = Cable + fields = [ + 'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + # # Interface connections # class InterfaceConnectionSerializer(ValidatedModelSerializer): - interface_a = NestedInterfaceSerializer() - interface_b = NestedInterfaceSerializer() + interface_a = serializers.SerializerMethodField() + interface_b = NestedInterfaceSerializer(source='connected_endpoint') connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) class Meta: - model = InterfaceConnection - fields = ['id', 'interface_a', 'interface_b', 'connection_status'] + model = Interface + fields = ['interface_a', 'interface_b', 'connection_status'] - -class NestedInterfaceConnectionSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail') - - class Meta: - model = InterfaceConnection - fields = ['id', 'url', 'connection_status'] - - -class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer): - """ - A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces. - """ - interface = serializers.SerializerMethodField(read_only=True) - connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) - - class Meta: - model = InterfaceConnection - fields = ['id', 'interface', 'connection_status'] - - def get_interface(self, obj): - return NestedInterfaceSerializer(self.context['interface'], context=self.context).data + def get_interface_a(self, obj): + context = {'request': self.context['request']} + return NestedInterfaceSerializer(instance=obj, context=context).data # @@ -768,11 +580,3 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = VirtualChassis fields = ['id', 'master', 'domain', 'tags'] - - -class NestedVirtualChassisSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - - class Meta: - model = VirtualChassis - fields = ['id', 'url'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 145cb7f09..006a61bad 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = DCIMRootView # Field choices -router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice') # Sites router.register(r'regions', views.RegionViewSet) @@ -39,6 +37,8 @@ router.register(r'console-server-port-templates', views.ConsoleServerPortTemplat router.register(r'power-port-templates', views.PowerPortTemplateViewSet) router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) router.register(r'interface-templates', views.InterfaceTemplateViewSet) +router.register(r'front-port-templates', views.FrontPortTemplateViewSet) +router.register(r'rear-port-templates', views.RearPortTemplateViewSet) router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) # Devices @@ -52,19 +52,24 @@ router.register(r'console-server-ports', views.ConsoleServerPortViewSet) router.register(r'power-ports', views.PowerPortViewSet) router.register(r'power-outlets', views.PowerOutletViewSet) router.register(r'interfaces', views.InterfaceViewSet) +router.register(r'front-ports', views.FrontPortViewSet) +router.register(r'rear-ports', views.RearPortViewSet) router.register(r'device-bays', views.DeviceBayViewSet) router.register(r'inventory-items', views.InventoryItemViewSet) # Connections -router.register(r'console-connections', views.ConsoleConnectionViewSet, base_name='consoleconnections') -router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections') -router.register(r'interface-connections', views.InterfaceConnectionViewSet) +router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections') +router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections') +router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections') + +# Cables +router.register(r'cables', views.CableViewSet) # Virtual chassis router.register(r'virtual-chassis', views.VirtualChassisViewSet) # Miscellaneous -router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') +router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') app_name = 'dcim-api' urlpatterns = router.urls diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index fd4d37096..2c0032cb4 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,8 +1,7 @@ -from __future__ import unicode_literals - from collections import OrderedDict from django.conf import settings +from django.db.models import F, Q from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -15,15 +14,17 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from dcim import filters from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable +from utilities.api import ( + get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, +) from . import serializers from .exceptions import MissingFilterException @@ -34,17 +35,51 @@ from .exceptions import MissingFilterException class DCIMFieldChoicesViewSet(FieldChoicesViewSet): fields = ( + (Cable, ['length_unit']), (Device, ['face', 'status']), (ConsolePort, ['connection_status']), - (Interface, ['form_factor', 'mode']), - (InterfaceConnection, ['connection_status']), + (Interface, ['connection_status', 'form_factor', 'mode']), (InterfaceTemplate, ['form_factor']), (PowerPort, ['connection_status']), - (Rack, ['type', 'width']), + (Rack, ['outer_unit', 'status', 'type', 'width']), (Site, ['status']), ) +# Mixins + +class CableTraceMixin(object): + + @action(detail=True, url_path='trace') + def trace(self, request, pk): + """ + Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination). + """ + obj = get_object_or_404(self.queryset.model, pk=pk) + + # Initialize the path array + path = [] + + for near_end, cable, far_end in obj.trace(): + + # Serialize each object + serializer_a = get_serializer_for_model(near_end, prefix='Nested') + x = serializer_a(near_end, context={'request': request}).data + if cable is not None: + y = serializers.TracedCableSerializer(cable, context={'request': request}).data + else: + y = None + if far_end is not None: + serializer_b = get_serializer_for_model(far_end, prefix='Nested') + z = serializer_b(far_end, context={'request': request}).data + else: + z = None + + path.append((x, y, z)) + + return Response(path) + + # # Regions # @@ -52,7 +87,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class RegionViewSet(ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer - filter_class = filters.RegionFilter + filterset_class = filters.RegionFilter # @@ -62,7 +97,7 @@ class RegionViewSet(ModelViewSet): class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags') serializer_class = serializers.SiteSerializer - filter_class = filters.SiteFilter + filterset_class = filters.SiteFilter @action(detail=True) def graphs(self, request, pk=None): @@ -82,7 +117,7 @@ class SiteViewSet(CustomFieldModelViewSet): class RackGroupViewSet(ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - filter_class = filters.RackGroupFilter + filterset_class = filters.RackGroupFilter # @@ -92,7 +127,7 @@ class RackGroupViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer - filter_class = filters.RackRoleFilter + filterset_class = filters.RackRoleFilter # @@ -102,7 +137,7 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags') serializer_class = serializers.RackSerializer - filter_class = filters.RackFilter + filterset_class = filters.RackFilter @action(detail=True) def units(self, request, pk=None): @@ -132,7 +167,7 @@ class RackViewSet(CustomFieldModelViewSet): class RackReservationViewSet(ModelViewSet): queryset = RackReservation.objects.select_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer - filter_class = filters.RackReservationFilter + filterset_class = filters.RackReservationFilter # Assign user from request def perform_create(self, serializer): @@ -146,7 +181,7 @@ class RackReservationViewSet(ModelViewSet): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer - filter_class = filters.ManufacturerFilter + filterset_class = filters.ManufacturerFilter # @@ -156,7 +191,7 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags') serializer_class = serializers.DeviceTypeSerializer - filter_class = filters.DeviceTypeFilter + filterset_class = filters.DeviceTypeFilter # @@ -166,37 +201,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): class ConsolePortTemplateViewSet(ModelViewSet): queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer - filter_class = filters.ConsolePortTemplateFilter + filterset_class = filters.ConsolePortTemplateFilter class ConsoleServerPortTemplateViewSet(ModelViewSet): queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer - filter_class = filters.ConsoleServerPortTemplateFilter + filterset_class = filters.ConsoleServerPortTemplateFilter class PowerPortTemplateViewSet(ModelViewSet): queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer - filter_class = filters.PowerPortTemplateFilter + filterset_class = filters.PowerPortTemplateFilter class PowerOutletTemplateViewSet(ModelViewSet): queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer - filter_class = filters.PowerOutletTemplateFilter + filterset_class = filters.PowerOutletTemplateFilter class InterfaceTemplateViewSet(ModelViewSet): queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer - filter_class = filters.InterfaceTemplateFilter + filterset_class = filters.InterfaceTemplateFilter + + +class FrontPortTemplateViewSet(ModelViewSet): + queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.FrontPortTemplateSerializer + filterset_class = filters.FrontPortTemplateFilter + + +class RearPortTemplateViewSet(ModelViewSet): + queryset = RearPortTemplate.objects.select_related('device_type__manufacturer') + serializer_class = serializers.RearPortTemplateSerializer + filterset_class = filters.RearPortTemplateFilter class DeviceBayTemplateViewSet(ModelViewSet): queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer - filter_class = filters.DeviceBayTemplateFilter + filterset_class = filters.DeviceBayTemplateFilter # @@ -206,7 +253,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer - filter_class = filters.DeviceRoleFilter + filterset_class = filters.DeviceRoleFilter # @@ -216,7 +263,7 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer - filter_class = filters.PlatformFilter + filterset_class = filters.PlatformFilter # @@ -230,7 +277,7 @@ class DeviceViewSet(CustomFieldModelViewSet): ).prefetch_related( 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) - filter_class = filters.DeviceFilter + filterset_class = filters.DeviceFilter def get_serializer_class(self): """ @@ -321,34 +368,54 @@ class DeviceViewSet(CustomFieldModelViewSet): # Device components # -class ConsolePortViewSet(ModelViewSet): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').prefetch_related('tags') +class ConsolePortViewSet(CableTraceMixin, ModelViewSet): + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.ConsolePortSerializer - filter_class = filters.ConsolePortFilter + filterset_class = filters.ConsolePortFilter -class ConsoleServerPortViewSet(ModelViewSet): - queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device').prefetch_related('tags') +class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): + queryset = ConsoleServerPort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.ConsoleServerPortSerializer - filter_class = filters.ConsoleServerPortFilter + filterset_class = filters.ConsoleServerPortFilter -class PowerPortViewSet(ModelViewSet): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').prefetch_related('tags') +class PowerPortViewSet(CableTraceMixin, ModelViewSet): + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.PowerPortSerializer - filter_class = filters.PowerPortFilter + filterset_class = filters.PowerPortFilter -class PowerOutletViewSet(ModelViewSet): - queryset = PowerOutlet.objects.select_related('device', 'connected_port__device').prefetch_related('tags') +class PowerOutletViewSet(CableTraceMixin, ModelViewSet): + queryset = PowerOutlet.objects.select_related( + 'device', 'connected_endpoint__device', 'cable' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.PowerOutletSerializer - filter_class = filters.PowerOutletFilter + filterset_class = filters.PowerOutletFilter -class InterfaceViewSet(ModelViewSet): - queryset = Interface.objects.select_related('device').prefetch_related('tags') +class InterfaceViewSet(CableTraceMixin, ModelViewSet): + queryset = Interface.objects.select_related( + 'device', '_connected_interface', '_connected_circuittermination', 'cable' + ).prefetch_related( + 'ip_addresses', 'tags' + ) serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter + filterset_class = filters.InterfaceFilter @action(detail=True) def graphs(self, request, pk=None): @@ -361,16 +428,36 @@ class InterfaceViewSet(ModelViewSet): return Response(serializer.data) +class FrontPortViewSet(ModelViewSet): + queryset = FrontPort.objects.select_related( + 'device__device_type__manufacturer', 'rear_port', 'cable' + ).prefetch_related( + 'tags' + ) + serializer_class = serializers.FrontPortSerializer + filterset_class = filters.FrontPortFilter + + +class RearPortViewSet(ModelViewSet): + queryset = RearPort.objects.select_related( + 'device__device_type__manufacturer', 'cable' + ).prefetch_related( + 'tags' + ) + serializer_class = serializers.RearPortSerializer + filterset_class = filters.RearPortFilter + + class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer - filter_class = filters.DeviceBayFilter + filterset_class = filters.DeviceBayFilter class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer - filter_class = filters.InventoryItemFilter + filterset_class = filters.InventoryItemFilter # @@ -378,21 +465,47 @@ class InventoryItemViewSet(ModelViewSet): # class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ) serializer_class = serializers.ConsolePortSerializer - filter_class = filters.ConsoleConnectionFilter + filterset_class = filters.ConsoleConnectionFilter class PowerConnectionViewSet(ListModelMixin, GenericViewSet): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ) serializer_class = serializers.PowerPortSerializer - filter_class = filters.PowerConnectionFilter + filterset_class = filters.PowerConnectionFilter class InterfaceConnectionViewSet(ModelViewSet): - queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device') + queryset = Interface.objects.select_related( + 'device', '_connected_interface', '_connected_circuittermination' + ).filter( + # Avoid duplicate connections by only selecting the lower PK in a connected pair + Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) | + Q(_connected_circuittermination__isnull=False) + ) serializer_class = serializers.InterfaceConnectionSerializer - filter_class = filters.InterfaceConnectionFilter + filterset_class = filters.InterfaceConnectionFilter + + +# +# Cables +# + +class CableViewSet(ModelViewSet): + queryset = Cable.objects.prefetch_related( + 'termination_a', 'termination_b' + ) + serializer_class = serializers.CableSerializer + filterset_class = filters.CableFilter # @@ -418,32 +531,39 @@ class ConnectedDeviceViewSet(ViewSet): * `peer_interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] - _device_param = Parameter('peer_device', 'query', - description='The name of the peer device', required=True, type=openapi.TYPE_STRING) - _interface_param = Parameter('peer_interface', 'query', - description='The name of the peer interface', required=True, type=openapi.TYPE_STRING) + _device_param = Parameter( + name='peer_device', + in_='query', + description='The name of the peer device', + required=True, + type=openapi.TYPE_STRING + ) + _interface_param = Parameter( + name='peer_interface', + in_='query', + description='The name of the peer interface', + required=True, + type=openapi.TYPE_STRING + ) def get_view_name(self): return "Connected Device Locator" @swagger_auto_schema( - manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer}) + manual_parameters=[_device_param, _interface_param], + responses={'200': serializers.DeviceSerializer} + ) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) - if not peer_device_name: - # TODO: remove this after 2.4 as the switch to using underscores is a breaking change - peer_device_name = request.query_params.get('peer-device') peer_interface_name = request.query_params.get(self._interface_param.name) - if not peer_interface_name: - # TODO: remove this after 2.4 as the switch to using underscores is a breaking change - peer_interface_name = request.query_params.get('peer-interface') + if not peer_device_name or not peer_interface_name: raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') # Determine local interface from peer interface's connection peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) - local_interface = peer_interface.connected_interface + local_interface = peer_interface._connected_interface if local_interface is None: return Response() diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index d61a46d98..78a243f84 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index d41825390..47a202893 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Rack types RACK_TYPE_2POST = 100 @@ -31,6 +29,20 @@ RACK_FACE_CHOICES = [ [RACK_FACE_REAR, 'Rear'], ] +# Rack statuses +RACK_STATUS_RESERVED = 0 +RACK_STATUS_AVAILABLE = 1 +RACK_STATUS_PLANNED = 2 +RACK_STATUS_ACTIVE = 3 +RACK_STATUS_DEPRECATED = 4 +RACK_STATUS_CHOICES = [ + [RACK_STATUS_ACTIVE, 'Active'], + [RACK_STATUS_PLANNED, 'Planned'], + [RACK_STATUS_RESERVED, 'Reserved'], + [RACK_STATUS_AVAILABLE, 'Available'], + [RACK_STATUS_DEPRECATED, 'Deprecated'], +] + # Parent/child device roles SUBDEVICE_ROLE_PARENT = True SUBDEVICE_ROLE_CHILD = False @@ -233,6 +245,36 @@ IFACE_MODE_CHOICES = [ [IFACE_MODE_TAGGED_ALL, 'Tagged All'], ] +# Pass-through port types +PORT_TYPE_8P8C = 1000 +PORT_TYPE_ST = 2000 +PORT_TYPE_SC = 2100 +PORT_TYPE_FC = 2200 +PORT_TYPE_LC = 2300 +PORT_TYPE_MTRJ = 2400 +PORT_TYPE_MPO = 2500 +PORT_TYPE_LSH = 2600 +PORT_TYPE_CHOICES = [ + [ + 'Copper', + [ + [PORT_TYPE_8P8C, '8P8C'], + ], + ], + [ + 'Fiber Optic', + [ + [PORT_TYPE_FC, 'FC'], + [PORT_TYPE_LC, 'LC'], + [PORT_TYPE_LSH, 'LSH'], + [PORT_TYPE_MPO, 'MPO'], + [PORT_TYPE_MTRJ, 'MTRJ'], + [PORT_TYPE_SC, 'SC'], + [PORT_TYPE_ST, 'ST'], + ] + ] +] + # Device statuses DEVICE_STATUS_OFFLINE = 0 DEVICE_STATUS_ACTIVE = 1 @@ -259,7 +301,7 @@ SITE_STATUS_CHOICES = [ [SITE_STATUS_RETIRED, 'Retired'], ] -# Bootstrap CSS classes for device statuses +# Bootstrap CSS classes for device/rack statuses STATUS_CLASSES = { 0: 'warning', 1: 'success', @@ -277,12 +319,81 @@ CONNECTION_STATUS_CHOICES = [ [CONNECTION_STATUS_CONNECTED, 'Connected'], ] -# Platform -> RPC client mappings -RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' -RPC_CLIENT_CISCO_IOS = 'cisco-ios' -RPC_CLIENT_OPENGEAR = 'opengear' -RPC_CLIENT_CHOICES = [ - [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], - [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], - [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], +# Cable endpoint types +CABLE_TERMINATION_TYPES = [ + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', ] + +# Cable types +CABLE_TYPE_CAT3 = 1300 +CABLE_TYPE_CAT5 = 1500 +CABLE_TYPE_CAT5E = 1510 +CABLE_TYPE_CAT6 = 1600 +CABLE_TYPE_CAT6A = 1610 +CABLE_TYPE_CAT7 = 1700 +CABLE_TYPE_MMF_OM1 = 3010 +CABLE_TYPE_MMF_OM2 = 3020 +CABLE_TYPE_MMF_OM3 = 3030 +CABLE_TYPE_MMF_OM4 = 3040 +CABLE_TYPE_SMF = 3500 +CABLE_TYPE_POWER = 5000 +CABLE_TYPE_CHOICES = ( + ( + 'Copper', ( + (CABLE_TYPE_CAT3, 'CAT3'), + (CABLE_TYPE_CAT5, 'CAT5'), + (CABLE_TYPE_CAT5E, 'CAT5e'), + (CABLE_TYPE_CAT6, 'CAT6'), + (CABLE_TYPE_CAT6A, 'CAT6a'), + (CABLE_TYPE_CAT7, 'CAT7'), + ), + ), + ( + 'Fiber', ( + (CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'), + (CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'), + (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'), + (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'), + (CABLE_TYPE_SMF, 'Singlemode Fiber'), + ), + ), + (CABLE_TYPE_POWER, 'Power'), +) + +CABLE_TERMINATION_TYPE_CHOICES = { + # (API endpoint, human-friendly name) + 'consoleport': ('console-ports', 'Console port'), + 'consoleserverport': ('console-server-ports', 'Console server port'), + 'powerport': ('power-ports', 'Power port'), + 'poweroutlet': ('power-outlets', 'Power outlet'), + 'interface': ('interfaces', 'Interface'), + 'frontport': ('front-ports', 'Front panel port'), + 'rearport': ('rear-ports', 'Rear panel port'), +} + +COMPATIBLE_TERMINATION_TYPES = { + 'consoleport': ['consoleserverport', 'frontport', 'rearport'], + 'consoleserverport': ['consoleport', 'frontport', 'rearport'], + 'powerport': ['poweroutlet'], + 'poweroutlet': ['powerport'], + 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], + 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], + 'circuittermination': ['interface', 'frontport', 'rearport'], +} + +LENGTH_UNIT_METER = 1200 +LENGTH_UNIT_CENTIMETER = 1100 +LENGTH_UNIT_MILLIMETER = 1000 +LENGTH_UNIT_FOOT = 2100 +LENGTH_UNIT_INCH = 2000 +CABLE_LENGTH_UNIT_CHOICES = ( + (LENGTH_UNIT_METER, 'Meters'), + (LENGTH_UNIT_CENTIMETER, 'Centimeters'), + (LENGTH_UNIT_FOOT, 'Feet'), + (LENGTH_UNIT_INCH, 'Inches'), +) +RACK_DIMENSION_UNIT_CHOICES = ( + (LENGTH_UNIT_MILLIMETER, 'Millimeters'), + (LENGTH_UNIT_INCH, 'Inches'), +) diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 4f38ec24e..8d4bfba35 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - -from netaddr import AddrFormatError, EUI, mac_unix_expanded - from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models +from netaddr import AddrFormatError, EUI, mac_unix_expanded class ASNField(models.BigIntegerField): diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index a8fb27954..0d9deadc2 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist @@ -9,17 +7,15 @@ from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.constants import COLOR_CHOICES from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter from virtualization.models import Cluster -from .constants import ( - DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES, - WIRELESS_IFACE_TYPES, IFACE_FF_CHOICES, -) +from .constants import * from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) @@ -33,7 +29,7 @@ class RegionFilter(django_filters.FilterSet): label='Parent region (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( - name='parent__slug', + field_name='parent__slug', queryset=Region.objects.all(), to_field_name='slug', label='Parent region (slug)', @@ -54,7 +50,10 @@ class RegionFilter(django_filters.FilterSet): class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -68,7 +67,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Region (ID)', ) region = django_filters.ModelMultipleChoiceFilter( - name='region__slug', + field_name='region__slug', queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', @@ -78,7 +77,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -120,7 +119,7 @@ class RackGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -148,7 +147,10 @@ class RackRoleFilter(django_filters.FilterSet): class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -159,7 +161,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -169,7 +171,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', @@ -179,26 +181,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=RACK_STATUS_CHOICES, + null_value=None + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=RackRole.objects.all(), to_field_name='slug', label='Role (slug)', ) + asset_tag = NullableCharFieldFilter() tag = TagFilter() class Meta: model = Rack - fields = ['name', 'serial', 'type', 'width', 'u_height', 'desc_units'] + fields = [ + 'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', + ] def search(self, queryset, name, value): if not value.strip(): @@ -207,12 +217,16 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(name__icontains=value) | Q(facility_id__icontains=value) | Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | Q(comments__icontains=value) ) class RackReservationFilter(django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -222,23 +236,23 @@ class RackReservationFilter(django_filters.FilterSet): label='Rack (ID)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='rack__site', + field_name='rack__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='rack__site__slug', + field_name='rack__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) group_id = django_filters.ModelMultipleChoiceFilter( - name='rack__group', + field_name='rack__group', queryset=RackGroup.objects.all(), label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='rack__group__slug', + field_name='rack__group__slug', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', @@ -248,7 +262,7 @@ class RackReservationFilter(django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -258,7 +272,7 @@ class RackReservationFilter(django_filters.FilterSet): label='User (ID)', ) user = django_filters.ModelMultipleChoiceFilter( - name='user', + field_name='user', queryset=User.objects.all(), to_field_name='username', label='User (name)', @@ -286,8 +300,11 @@ class ManufacturerFilter(django_filters.FilterSet): fields = ['name', 'slug'] -class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class DeviceTypeFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -297,18 +314,41 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', ) + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) tag = TagFilter() class Meta: model = DeviceType fields = [ - 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', + 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', ] def search(self, queryset, name, value): @@ -321,11 +361,32 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(comments__icontains=value) ) + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleport_templates__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverport_templates__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerport_templates__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlet_templates__isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interface_templates__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontport_templates__isnull=value, + rearport_templates__isnull=value + ) + class DeviceTypeComponentFilterSet(django_filters.FilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceType.objects.all(), - name='device_type_id', + field_name='device_type_id', label='Device type (ID)', ) @@ -365,6 +426,20 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): fields = ['name', 'form_factor', 'mgmt_only'] +class FrontPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = FrontPortTemplate + fields = ['name', 'type'] + + +class RearPortTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = RearPortTemplate + fields = ['name', 'type'] + + class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: @@ -381,12 +456,12 @@ class DeviceRoleFilter(django_filters.FilterSet): class PlatformFilter(django_filters.FilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='manufacturer', + field_name='manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -397,19 +472,22 @@ class PlatformFilter(django_filters.FilterSet): fields = ['name', 'slug'] -class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') +class DeviceFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', + field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer__slug', + field_name='device_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -419,12 +497,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device type (ID)', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='device_role_id', + field_name='device_role_id', queryset=DeviceRole.objects.all(), label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='device_role__slug', + field_name='device_role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -434,7 +512,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -444,7 +522,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platform__slug', + field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', @@ -453,12 +531,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): asset_tag = NullableCharFieldFilter() region_id = django_filters.NumberFilter( method='filter_region', - name='pk', + field_name='pk', label='Region (ID)', ) region = django_filters.CharFilter( method='filter_region', - name='slug', + field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -466,18 +544,18 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', ) rack_group_id = django_filters.ModelMultipleChoiceFilter( - name='rack__group', + field_name='rack__group', queryset=RackGroup.objects.all(), label='Rack group (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( - name='rack', + field_name='rack', queryset=Rack.objects.all(), label='Rack (ID)', ) @@ -486,7 +564,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VM cluster (ID)', ) model = django_filters.ModelMultipleChoiceFilter( - name='device_type__slug', + field_name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', label='Device model (slug)', @@ -496,21 +574,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): null_value=None ) is_full_depth = django_filters.BooleanFilter( - name='device_type__is_full_depth', + field_name='device_type__is_full_depth', label='Is full depth', ) - is_console_server = django_filters.BooleanFilter( - name='device_type__is_console_server', - label='Is a console server', - ) - is_pdu = django_filters.BooleanFilter( - name='device_type__is_pdu', - label='Is a PDU', - ) - is_network_device = django_filters.BooleanFilter( - name='device_type__is_network_device', - label='Is a network device', - ) mac_address = django_filters.CharFilter( method='_mac_address', label='MAC address', @@ -520,10 +586,34 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Has a primary IP', ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( - name='virtual_chassis', + field_name='virtual_chassis', queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) tag = TagFilter() class Meta: @@ -573,6 +663,27 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(primary_ip6__isnull=False) ) + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleports__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverports__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerports__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlets_isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interfaces__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontports__isnull=value, + rearports__isnull=value + ) + class DeviceComponentFilterSet(django_filters.FilterSet): device_id = django_filters.ModelChoiceFilter( @@ -588,54 +699,78 @@ class DeviceComponentFilterSet(django_filters.FilterSet): class ConsolePortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = ConsolePort - fields = ['name'] + fields = ['name', 'connection_status'] class ConsoleServerPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = ConsoleServerPort - fields = ['name'] + fields = ['name', 'connection_status'] class PowerPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = PowerPort - fields = ['name'] + fields = ['name', 'connection_status'] class PowerOutletFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) class Meta: model = PowerOutlet - fields = ['name'] + fields = ['name', 'connection_status'] class InterfaceFilter(django_filters.FilterSet): """ - Not using DeviceComponentFilterSet for Interfaces because we need to glean the ordering logic from the parent - Device's DeviceType. + Not using DeviceComponentFilterSet for Interfaces because we need to check for VirtualChassis membership. """ device = django_filters.CharFilter( method='filter_device', - name='name', + field_name='name', label='Device', ) device_id = django_filters.NumberFilter( method='filter_device', - name='pk', + field_name='pk', label='Device (ID)', ) + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) type = django_filters.CharFilter( method='filter_type', label='Interface type', ) lag_id = django_filters.ModelMultipleChoiceFilter( - name='lag', + field_name='lag', queryset=Interface.objects.all(), label='LAG interface (ID)', ) @@ -659,14 +794,13 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'enabled', 'mtu', 'mgmt_only'] + fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] def filter_device(self, queryset, name, value): try: - device = Device.objects.select_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] - ordering = device.device_type.interface_ordering - return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering) + device = Device.objects.get(**{name: value}) + vc_interface_ids = device.vc_interfaces.values_list('id', flat=True) + return queryset.filter(pk__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() @@ -708,6 +842,30 @@ class InterfaceFilter(django_filters.FilterSet): return queryset.none() +class FrontPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) + + class Meta: + model = FrontPort + fields = ['name', 'type'] + + +class RearPortFilter(DeviceComponentFilterSet): + cabled = django_filters.BooleanFilter( + field_name='cable', + lookup_expr='isnull', + exclude=True + ) + + class Meta: + model = RearPort + fields = ['name', 'type'] + + class DeviceBayFilter(DeviceComponentFilterSet): class Meta: @@ -738,7 +896,7 @@ class InventoryItemFilter(DeviceComponentFilterSet): label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer__slug', + field_name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -768,23 +926,23 @@ class VirtualChassisFilter(django_filters.FilterSet): label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='master__site', + field_name='master__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='master__site__slug', + field_name='master__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( - name='master__tenant', + field_name='master__tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='master__tenant__slug', + field_name='master__tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -805,6 +963,28 @@ class VirtualChassisFilter(django_filters.FilterSet): return queryset.filter(qs_filter) +class CableFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + type = django_filters.MultipleChoiceFilter( + choices=CABLE_TYPE_CHOICES + ) + color = django_filters.MultipleChoiceFilter( + choices=COLOR_CHOICES + ) + + class Meta: + model = Cable + fields = ['type', 'status', 'color', 'length', 'length_unit'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(label__icontains=value) + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', @@ -822,14 +1002,14 @@ class ConsoleConnectionFilter(django_filters.FilterSet): def filter_site(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(cs_port__device__site__slug=value) + return queryset.filter(connected_endpoint__device__site__slug=value) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(device__name__icontains=value) | - Q(cs_port__device__name__icontains=value) + Q(connected_endpoint__device__name__icontains=value) ) @@ -850,14 +1030,14 @@ class PowerConnectionFilter(django_filters.FilterSet): def filter_site(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(power_outlet__device__site__slug=value) + return queryset.filter(connected_endpoint__device__site__slug=value) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(device__name__icontains=value) | - Q(power_outlet__device__name__icontains=value) + Q(connected_endpoint__device__name__icontains=value) ) @@ -872,21 +1052,21 @@ class InterfaceConnectionFilter(django_filters.FilterSet): ) class Meta: - model = InterfaceConnection + model = Interface fields = ['connection_status'] def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(interface_a__device__site__slug=value) | - Q(interface_b__device__site__slug=value) + Q(device__site__slug=value) | + Q(_connected_interface__device__site__slug=value) ) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(interface_a__device__name__icontains=value) | - Q(interface_b__device__name__icontains=value) + Q(device__name__icontains=value) | + Q(_connected_interface__device__name__icontains=value) ) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 761f1ba69..215fbb702 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -76,10 +76,7 @@ "model": "MX960", "slug": "mx960", "u_height": 16, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -92,10 +89,7 @@ "model": "EX9214", "slug": "ex9214", "u_height": 16, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -108,10 +102,7 @@ "model": "QFX5100-24Q", "slug": "qfx5100-24q", "u_height": 1, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -124,10 +115,7 @@ "model": "QFX5100-48S", "slug": "qfx5100-48s", "u_height": 1, - "is_full_depth": true, - "is_console_server": false, - "is_pdu": false, - "is_network_device": true + "is_full_depth": true } }, { @@ -140,10 +128,7 @@ "model": "CM4148", "slug": "cm4148", "u_height": 1, - "is_full_depth": true, - "is_console_server": true, - "is_pdu": false, - "is_network_device": false + "is_full_depth": true } }, { @@ -156,10 +141,7 @@ "model": "CWG-24VYM415C9", "slug": "cwg-24vym415c9", "u_height": 0, - "is_full_depth": false, - "is_console_server": false, - "is_pdu": true, - "is_network_device": false + "is_full_depth": false } }, { @@ -1903,8 +1885,7 @@ "pk": 1, "fields": { "name": "Juniper Junos", - "slug": "juniper-junos", - "rpc_client": "juniper-junos" + "slug": "juniper-junos" } }, { @@ -1912,8 +1893,7 @@ "pk": 2, "fields": { "name": "Opengear", - "slug": "opengear", - "rpc_client": "opengear" + "slug": "opengear" } }, { @@ -2153,7 +2133,7 @@ "fields": { "device": 1, "name": "Console (RE0)", - "cs_port": 27, + "connected_endpoint": 27, "connection_status": true } }, @@ -2163,7 +2143,7 @@ "fields": { "device": 1, "name": "Console (RE1)", - "cs_port": 38, + "connected_endpoint": 38, "connection_status": true } }, @@ -2173,7 +2153,7 @@ "fields": { "device": 2, "name": "Console (RE0)", - "cs_port": 5, + "connected_endpoint": 5, "connection_status": true } }, @@ -2183,7 +2163,7 @@ "fields": { "device": 2, "name": "Console (RE1)", - "cs_port": 16, + "connected_endpoint": 16, "connection_status": true } }, @@ -2193,7 +2173,7 @@ "fields": { "device": 3, "name": "Console", - "cs_port": 49, + "connected_endpoint": 49, "connection_status": true } }, @@ -2203,7 +2183,7 @@ "fields": { "device": 4, "name": "Console", - "cs_port": 48, + "connected_endpoint": 48, "connection_status": true } }, @@ -2213,7 +2193,7 @@ "fields": { "device": 5, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2223,7 +2203,7 @@ "fields": { "device": 6, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2233,7 +2213,7 @@ "fields": { "device": 7, "name": "Console (RE0)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2243,7 +2223,7 @@ "fields": { "device": 7, "name": "Console (RE1)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2253,7 +2233,7 @@ "fields": { "device": 8, "name": "Console (RE0)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2263,7 +2243,7 @@ "fields": { "device": 8, "name": "Console (RE1)", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2273,7 +2253,7 @@ "fields": { "device": 9, "name": "Console", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2283,7 +2263,7 @@ "fields": { "device": 11, "name": "Serial", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2293,7 +2273,7 @@ "fields": { "device": 12, "name": "Serial", - "cs_port": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2687,7 +2667,7 @@ "fields": { "device": 1, "name": "PEM0", - "power_outlet": 25, + "connected_endpoint": 25, "connection_status": true } }, @@ -2697,7 +2677,7 @@ "fields": { "device": 1, "name": "PEM1", - "power_outlet": 49, + "connected_endpoint": 49, "connection_status": true } }, @@ -2707,7 +2687,7 @@ "fields": { "device": 1, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2717,7 +2697,7 @@ "fields": { "device": 1, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2727,7 +2707,7 @@ "fields": { "device": 2, "name": "PEM0", - "power_outlet": 26, + "connected_endpoint": 26, "connection_status": true } }, @@ -2737,7 +2717,7 @@ "fields": { "device": 2, "name": "PEM1", - "power_outlet": 50, + "connected_endpoint": 50, "connection_status": true } }, @@ -2747,7 +2727,7 @@ "fields": { "device": 2, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2757,7 +2737,7 @@ "fields": { "device": 2, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2767,7 +2747,7 @@ "fields": { "device": 4, "name": "PSU0", - "power_outlet": 28, + "connected_endpoint": 28, "connection_status": true } }, @@ -2777,7 +2757,7 @@ "fields": { "device": 4, "name": "PSU1", - "power_outlet": 52, + "connected_endpoint": 52, "connection_status": true } }, @@ -2787,7 +2767,7 @@ "fields": { "device": 5, "name": "PSU0", - "power_outlet": 56, + "connected_endpoint": 56, "connection_status": true } }, @@ -2797,7 +2777,7 @@ "fields": { "device": 5, "name": "PSU1", - "power_outlet": 32, + "connected_endpoint": 32, "connection_status": true } }, @@ -2807,7 +2787,7 @@ "fields": { "device": 3, "name": "PSU0", - "power_outlet": 27, + "connected_endpoint": 27, "connection_status": true } }, @@ -2817,7 +2797,7 @@ "fields": { "device": 3, "name": "PSU1", - "power_outlet": 51, + "connected_endpoint": 51, "connection_status": true } }, @@ -2827,7 +2807,7 @@ "fields": { "device": 7, "name": "PEM0", - "power_outlet": 53, + "connected_endpoint": 53, "connection_status": true } }, @@ -2837,7 +2817,7 @@ "fields": { "device": 7, "name": "PEM1", - "power_outlet": 29, + "connected_endpoint": 29, "connection_status": true } }, @@ -2847,7 +2827,7 @@ "fields": { "device": 7, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2857,7 +2837,7 @@ "fields": { "device": 7, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2867,7 +2847,7 @@ "fields": { "device": 8, "name": "PEM0", - "power_outlet": 54, + "connected_endpoint": 54, "connection_status": true } }, @@ -2877,7 +2857,7 @@ "fields": { "device": 8, "name": "PEM1", - "power_outlet": 30, + "connected_endpoint": 30, "connection_status": true } }, @@ -2887,7 +2867,7 @@ "fields": { "device": 8, "name": "PEM2", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2897,7 +2877,7 @@ "fields": { "device": 8, "name": "PEM3", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -2907,7 +2887,7 @@ "fields": { "device": 6, "name": "PSU0", - "power_outlet": 55, + "connected_endpoint": 55, "connection_status": true } }, @@ -2917,7 +2897,7 @@ "fields": { "device": 6, "name": "PSU1", - "power_outlet": 31, + "connected_endpoint": 31, "connection_status": true } }, @@ -2927,7 +2907,7 @@ "fields": { "device": 9, "name": "PSU", - "power_outlet": null, + "connected_endpoint": null, "connection_status": true } }, @@ -5748,158 +5728,5 @@ "mgmt_only": true, "description": "" } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 3, - "fields": { - "interface_a": 99, - "interface_b": 15, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 4, - "fields": { - "interface_a": 100, - "interface_b": 153, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 5, - "fields": { - "interface_a": 46, - "interface_b": 14, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 6, - "fields": { - "interface_a": 47, - "interface_b": 152, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 7, - "fields": { - "interface_a": 91, - "interface_b": 144, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 8, - "fields": { - "interface_a": 92, - "interface_b": 145, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 16, - "fields": { - "interface_a": 189, - "interface_b": 37, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 17, - "fields": { - "interface_a": 192, - "interface_b": 175, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 18, - "fields": { - "interface_a": 195, - "interface_b": 41, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 19, - "fields": { - "interface_a": 198, - "interface_b": 179, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 20, - "fields": { - "interface_a": 191, - "interface_b": 197, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 21, - "fields": { - "interface_a": 194, - "interface_b": 200, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 22, - "fields": { - "interface_a": 9, - "interface_b": 218, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 23, - "fields": { - "interface_a": 8, - "interface_b": 206, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 24, - "fields": { - "interface_a": 7, - "interface_b": 212, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 25, - "fields": { - "interface_a": 217, - "interface_b": 205, - "connection_status": true - } -}, -{ - "model": "dcim.interfaceconnection", - "pk": 26, - "fields": { - "interface_a": 216, - "interface_b": 211, - "connection_status": true - } } ] diff --git a/netbox/dcim/fixtures/initial_data.json b/netbox/dcim/fixtures/initial_data.json index e765de227..83f79e3a3 100644 --- a/netbox/dcim/fixtures/initial_data.json +++ b/netbox/dcim/fixtures/initial_data.json @@ -149,8 +149,7 @@ "pk": 1, "fields": { "name": "Cisco IOS", - "slug": "cisco-ios", - "rpc_client": "cisco-ios" + "slug": "cisco-ios" } }, { @@ -158,8 +157,7 @@ "pk": 2, "fields": { "name": "Cisco NX-OS", - "slug": "cisco-nx-os", - "rpc_client": "" + "slug": "cisco-nx-os" } }, { @@ -167,8 +165,7 @@ "pk": 3, "fields": { "name": "Juniper Junos", - "slug": "juniper-junos", - "rpc_client": "juniper-junos" + "slug": "juniper-junos" } }, { @@ -176,8 +173,7 @@ "pk": 4, "fields": { "name": "Arista EOS", - "slug": "arista-eos", - "rpc_client": "" + "slug": "arista-eos" } }, { @@ -185,8 +181,7 @@ "pk": 5, "fields": { "name": "Linux", - "slug": "linux", - "rpc_client": "" + "slug": "linux" } }, { @@ -194,8 +189,7 @@ "pk": 6, "fields": { "name": "Opengear", - "slug": "opengear", - "rpc_client": "opengear" + "slug": "opengear" } } ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4a75ac386..86da72a88 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,10 +1,10 @@ -from __future__ import unicode_literals - import re from django import forms from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, Q from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField @@ -16,22 +16,19 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, - FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, + ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, FilterChoiceField, + FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithPK, SmallTextarea, + SlugField, BOOLEAN_WITH_BLANK_CHOICES, COLOR_CHOICES, + ) from virtualization.models import Cluster, ClusterGroup -from .constants import ( - CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG, - IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, - RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, - SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES, -) +from .constants import * from .models import ( - DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, - Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, - Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, - RackRole, Region, Site, VirtualChassis + Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, + Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, + InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, + RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) DEVICE_BY_PK_RE = r'{\d+\}' @@ -72,7 +69,9 @@ class RegionForm(BootstrapMixin, forms.ModelForm): class Meta: model = Region - fields = ['parent', 'name', 'slug'] + fields = [ + 'parent', 'name', 'slug', + ] class RegionCSVForm(forms.ModelForm): @@ -97,7 +96,10 @@ class RegionCSVForm(forms.ModelForm): class RegionFilterForm(BootstrapMixin, forms.Form): model = Site - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) # @@ -105,10 +107,15 @@ class RegionFilterForm(BootstrapMixin, forms.Form): # class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): - region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) + region = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False + ) slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Site @@ -118,8 +125,16 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'contact_email', 'comments', 'tags', ] widgets = { - 'physical_address': SmallTextarea(attrs={'rows': 3}), - 'shipping_address': SmallTextarea(attrs={'rows': 3}), + 'physical_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), + 'shipping_address': SmallTextarea( + attrs={ + 'rows': 3, + } + ), } help_texts = { 'name': "Full name of the site", @@ -203,12 +218,17 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) class Meta: - nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] + nullable_fields = [ + 'region', 'tenant', 'asn', 'description', 'time_zone', + ] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) status = AnnotatedMultipleChoiceField( choices=SITE_STATUS_CHOICES, annotate=Site.objects.all(), @@ -236,7 +256,9 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackGroup - fields = ['site', 'name', 'slug'] + fields = [ + 'site', 'name', 'slug', + ] class RackGroupCSVForm(forms.ModelForm): @@ -259,7 +281,12 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') + site = FilterChoiceField( + queryset=Site.objects.annotate( + filter_count=Count('rack_groups') + ), + to_field_name='slug' + ) # @@ -271,7 +298,9 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = [ + 'name', 'slug', 'color', + ] class RackRoleCSVForm(forms.ModelForm): @@ -302,13 +331,15 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): ) ) comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Rack fields = [ - 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'tags', + 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', + 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -317,7 +348,11 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'u_height': "Height in rack units", } widgets = { - 'site': forms.Select(attrs={'filter-for': 'group'}), + 'site': forms.Select( + attrs={ + 'filter-for': 'group', + } + ), } @@ -343,6 +378,11 @@ class RackCSVForm(forms.ModelForm): 'invalid_choice': 'Tenant not found.', } ) + status = CSVChoiceField( + choices=RACK_STATUS_CHOICES, + required=False, + help_text='Operational status' + ) role = forms.ModelChoiceField( queryset=RackRole.objects.all(), required=False, @@ -364,6 +404,11 @@ class RackCSVForm(forms.ModelForm): ), help_text='Rail-to-rail width (in inches)' ) + outer_unit = CSVChoiceField( + choices=RACK_DIMENSION_UNIT_CHOICES, + required=False, + help_text='Unit for outer dimensions' + ) class Meta: model = Rack @@ -375,7 +420,7 @@ class RackCSVForm(forms.ModelForm): def clean(self): - super(RackCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') @@ -403,41 +448,117 @@ class RackCSVForm(forms.ModelForm): class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') - group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False) - serial = forms.CharField(max_length=50, required=False, label='Serial Number') - type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') - width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') - u_height = forms.IntegerField(required=False, label='Height (U)') - desc_units = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Descending units') - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Rack.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + group = forms.ModelChoiceField( + queryset=RackGroup.objects.all(), + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(RACK_STATUS_CHOICES), + required=False, + initial='' + ) + role = forms.ModelChoiceField( + queryset=RackRole.objects.all(), + required=False + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + asset_tag = forms.CharField( + max_length=50, + required=False + ) + type = forms.ChoiceField( + choices=add_blank_choice(RACK_TYPE_CHOICES), + required=False + ) + width = forms.ChoiceField( + choices=add_blank_choice(RACK_WIDTH_CHOICES), + required=False + ) + u_height = forms.IntegerField( + required=False, + label='Height (U)' + ) + desc_units = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Descending units' + ) + outer_width = forms.IntegerField( + required=False, + min_value=1 + ) + outer_depth = forms.IntegerField( + required=False, + min_value=1 + ) + outer_unit = forms.ChoiceField( + choices=add_blank_choice(RACK_DIMENSION_UNIT_CHOICES), + required=False + ) + comments = CommentField( + widget=SmallTextarea + ) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments'] + nullable_fields = [ + 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ] class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('racks')), + queryset=Site.objects.annotate( + filter_count=Count('racks') + ), to_field_name='slug' ) group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks')), + queryset=RackGroup.objects.select_related( + 'site' + ).annotate( + filter_count=Count('racks') + ), label='Rack group', null_label='-- None --' ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('racks')), + queryset=Tenant.objects.annotate( + filter_count=Count('racks') + ), to_field_name='slug', null_label='-- None --' ) + status = AnnotatedMultipleChoiceField( + choices=RACK_STATUS_CHOICES, + annotate=Rack.objects.all(), + annotate_field='status', + required=False + ) role = FilterChoiceField( - queryset=RackRole.objects.annotate(filter_count=Count('racks')), + queryset=RackRole.objects.annotate( + filter_count=Count('racks') + ), to_field_name='slug', null_label='-- None --' ) @@ -448,16 +569,29 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): - units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) - user = forms.ModelChoiceField(queryset=User.objects.order_by('username')) + units = SimpleArrayField( + base_field=forms.IntegerField(), + widget=ArrayFieldSelectMultiple( + attrs={ + 'size': 10, + } + ) + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ) + ) class Meta: model = RackReservation - fields = ['units', 'user', 'tenant_group', 'tenant', 'description'] + fields = [ + 'units', 'user', 'tenant_group', 'tenant', 'description', + ] def __init__(self, *args, **kwargs): - super(RackReservationForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Populate rack unit choices self.fields['units'].widget.choices = self._get_unit_choices() @@ -473,28 +607,53 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): class RackReservationFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('racks__reservations')), + queryset=Site.objects.annotate( + filter_count=Count('racks__reservations') + ), to_field_name='slug' ) group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')), + queryset=RackGroup.objects.select_related( + 'site' + ).annotate( + filter_count=Count('racks__reservations') + ), label='Rack group', null_label='-- None --' ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')), + queryset=Tenant.objects.annotate( + filter_count=Count('rackreservations') + ), to_field_name='slug', null_label='-- None --' ) class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput) - user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=RackReservation.objects.all(), + widget=forms.MultipleHiddenInput() + ) + user = forms.ModelChoiceField( + queryset=User.objects.order_by( + 'username' + ), + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: nullable_fields = [] @@ -509,10 +668,13 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): class Meta: model = Manufacturer - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ManufacturerCSVForm(forms.ModelForm): + class Meta: model = Manufacturer fields = Manufacturer.csv_headers @@ -527,18 +689,19 @@ class ManufacturerCSVForm(forms.ModelForm): # class DeviceTypeForm(BootstrapMixin, CustomFieldForm): - slug = SlugField(slug_source='model') - tags = TagField(required=False) + slug = SlugField( + slug_source='model' + ) + tags = TagField( + required=False + ) class Meta: model = DeviceType fields = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', 'tags', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', + 'tags', ] - labels = { - 'interface_ordering': 'Order interfaces by', - } class DeviceTypeCSVForm(forms.ModelForm): @@ -556,11 +719,6 @@ class DeviceTypeCSVForm(forms.ModelForm): required=False, help_text='Parent/child status' ) - interface_ordering = CSVChoiceField( - choices=IFACE_ORDERING_CHOICES, - required=False, - help_text='Interface ordering' - ) class Meta: model = DeviceType @@ -572,17 +730,22 @@ class DeviceTypeCSVForm(forms.ModelForm): class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) - manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) - u_height = forms.IntegerField(min_value=1, required=False) - is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth') - interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False) - is_console_server = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Is a console server' + pk = forms.ModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + widget=forms.MultipleHiddenInput() ) - is_pdu = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a PDU') - is_network_device = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Is a network device' + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + u_height = forms.IntegerField( + min_value=1, + required=False + ) + is_full_depth = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is full depth' ) class Meta: @@ -591,25 +754,64 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): model = DeviceType - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) manufacturer = FilterChoiceField( - queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), + queryset=Manufacturer.objects.annotate( + filter_count=Count('device_types') + ), to_field_name='slug' ) - is_console_server = forms.BooleanField( - required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'})) - is_pdu = forms.BooleanField( - required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'}) - ) - is_network_device = forms.BooleanField( - required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'}) - ) subdevice_role = forms.NullBooleanField( - required=False, label='Subdevice role', widget=forms.Select(choices=( - ('', '---------'), - (SUBDEVICE_ROLE_PARENT, 'Parent'), - (SUBDEVICE_ROLE_CHILD, 'Child'), - )) + required=False, + label='Subdevice role', + widget=forms.Select( + choices=add_blank_choice(SUBDEVICE_ROLE_CHOICES) + ) + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) @@ -621,95 +823,229 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class ConsolePortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class ConsoleServerPortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class PowerPortTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class PowerOutletTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate - fields = ['device_type', 'name', 'form_factor', 'mgmt_only'] + fields = [ + 'device_type', 'name', 'form_factor', 'mgmt_only', + ] widgets = { 'device_type': forms.HiddenInput(), } class InterfaceTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - mgmt_only = forms.BooleanField(required=False, label='OOB Management') + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only' + ) class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput) - form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) - mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') + pk = forms.ModelMultipleChoiceField( + queryset=InterfaceTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + form_factor = forms.ChoiceField( + choices=add_blank_choice(IFACE_FF_CHOICES), + required=False + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) class Meta: nullable_fields = [] +class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = FrontPortTemplate + fields = [ + 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class FrontPortTemplateCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES + ) + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.' + ) + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in self.parent.frontport_templates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPortTemplate.objects.filter(device_type=self.parent) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = RearPortTemplate + fields = [ + 'device_type', 'name', 'type', 'positions', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class RearPortTemplateCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES + ) + positions = forms.IntegerField( + min_value=1, + max_value=64, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate - fields = ['device_type', 'name'] + fields = [ + 'device_type', 'name', + ] widgets = { 'device_type': forms.HiddenInput(), } class DeviceBayTemplateCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') + name_pattern = ExpandableNameField( + label='Name' + ) # @@ -721,7 +1057,9 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = [ + 'name', 'slug', 'color', 'vm_role', + ] class DeviceRoleCSVForm(forms.ModelForm): @@ -745,7 +1083,9 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'rpc_client'] + fields = [ + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', + ] widgets = { 'napalm_args': SmallTextarea(), } @@ -779,7 +1119,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.Select( - attrs={'filter-for': 'rack'} + attrs={ + 'filter-for': 'rack', + } ) ) rack = ChainedModelChoiceField( @@ -791,7 +1133,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', - attrs={'filter-for': 'position'} + attrs={ + 'filter-for': 'position', + } ) ) position = forms.TypedChoiceField( @@ -806,7 +1150,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), widget=forms.Select( - attrs={'filter-for': 'device_type'} + attrs={ + 'filter-for': 'device_type', + } ) ) device_type = ChainedModelChoiceField( @@ -851,10 +1197,15 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): help_texts = { 'device_role': "The function this device serves", 'serial': "Chassis serial number", - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context" + 'local_context_data': "Local config context data overwrites all source contexts in the final rendered " + "config context", } widgets = { - 'face': forms.Select(attrs={'filter-for': 'position'}), + 'face': forms.Select( + attrs={ + 'filter-for': 'position', + } + ), } def __init__(self, *args, **kwargs): @@ -867,7 +1218,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): initial['manufacturer'] = instance.device_type.manufacturer kwargs['initial'] = initial - super(DeviceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: @@ -991,7 +1342,7 @@ class BaseDeviceCSVForm(forms.ModelForm): def clean(self): - super(BaseDeviceCSVForm, self).clean() + super().clean() manufacturer = self.cleaned_data.get('manufacturer') model_name = self.cleaned_data.get('model_name') @@ -1044,7 +1395,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): def clean(self): - super(DeviceCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') rack_group = self.cleaned_data.get('rack_group') @@ -1093,7 +1444,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): def clean(self): - super(ChildDeviceCSVForm, self).clean() + super().clean() parent = self.cleaned_data.get('parent') device_bay_name = self.cleaned_data.get('device_bay_name') @@ -1110,57 +1461,108 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) - device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') - device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='') - serial = forms.CharField(max_length=50, required=False, label='Serial Number') + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device_type = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label='Type' + ) + device_role = forms.ModelChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label='Role' + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(DEVICE_STATUS_CHOICES), + required=False, + initial='' + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) class Meta: - nullable_fields = ['tenant', 'platform', 'serial'] + nullable_fields = [ + 'tenant', 'platform', 'serial', + ] class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) region = FilterTreeNodeMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('devices')), + queryset=Site.objects.annotate( + filter_count=Count('devices') + ), to_field_name='slug', ) rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), + queryset=RackGroup.objects.select_related( + 'site' + ).annotate( + filter_count=Count('racks__devices') + ), label='Rack group', ) rack_id = FilterChoiceField( - queryset=Rack.objects.annotate(filter_count=Count('devices')), + queryset=Rack.objects.annotate( + filter_count=Count('devices') + ), label='Rack', null_label='-- None --', ) role = FilterChoiceField( - queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), + queryset=DeviceRole.objects.annotate( + filter_count=Count('devices') + ), to_field_name='slug', ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('devices')), + queryset=Tenant.objects.annotate( + filter_count=Count('devices') + ), to_field_name='slug', null_label='-- None --', ) - manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer') + manufacturer_id = FilterChoiceField( + queryset=Manufacturer.objects.all(), + label='Manufacturer' + ) device_type_id = FilterChoiceField( - queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate( + queryset=DeviceType.objects.select_related( + 'manufacturer' + ).order_by( + 'model' + ).annotate( filter_count=Count('instances'), ), label='Model', ) platform = FilterChoiceField( - queryset=Platform.objects.annotate(filter_count=Count('devices')), + queryset=Platform.objects.annotate( + filter_count=Count('devices') + ), to_field_name='slug', null_label='-- None --', ) @@ -1170,15 +1572,58 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): annotate_field='status', required=False ) - mac_address = forms.CharField(required=False, label='MAC address') + mac_address = forms.CharField( + required=False, + label='MAC address' + ) has_primary_ip = forms.NullBooleanField( required=False, label='Has a primary IP', - widget=forms.Select(choices=[ - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), - ]) + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) @@ -1187,16 +1632,37 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): # class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) - name_pattern = ExpandableNameField(label='Name') + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) + name_pattern = ExpandableNameField( + label='Name' + ) class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - enabled = forms.BooleanField(required=False, initial=True) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mgmt_only = forms.BooleanField(required=False, label='OOB Management') - description = forms.CharField(max_length=100, required=False) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mgmt_only = forms.BooleanField( + required=False, + label='Management only' + ) + description = forms.CharField( + max_length=100, + required=False + ) # @@ -1204,170 +1670,27 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): # class ConsolePortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = ConsolePort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class ConsolePortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class ConsoleConnectionCSVForm(forms.ModelForm): - console_server = FlexibleModelChoiceField( - queryset=Device.objects.filter(device_type__is_console_server=True), - to_field_name='name', - help_text='Console server name or ID', - error_messages={ - 'invalid_choice': 'Console server not found', - } + name_pattern = ExpandableNameField( + label='Name' ) - cs_port = forms.CharField( - help_text='Console server port name' + tags = TagField( + required=False ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - console_port = forms.CharField( - help_text='Console port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = ConsolePort - fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] - - def clean_console_port(self): - - console_port_name = self.cleaned_data.get('console_port') - if not self.cleaned_data.get('device') or not console_port_name: - return None - - try: - # Retrieve console port by name - consoleport = ConsolePort.objects.get( - device=self.cleaned_data['device'], name=console_port_name - ) - # Check if the console port is already connected - if consoleport.cs_port is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], console_port_name - )) - except ConsolePort.DoesNotExist: - raise forms.ValidationError("Invalid console port ({} {})".format( - self.cleaned_data['device'], console_port_name - )) - - self.instance = consoleport - return consoleport - - def clean_cs_port(self): - - cs_port_name = self.cleaned_data.get('cs_port') - if not self.cleaned_data.get('console_server') or not cs_port_name: - return None - - try: - # Retrieve console server port by name - cs_port = ConsoleServerPort.objects.get( - device=self.cleaned_data['console_server'], name=cs_port_name - ) - # Check if the console server port is already connected - if ConsolePort.objects.filter(cs_port=cs_port).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['console_server'], cs_port_name - )) - except ConsoleServerPort.DoesNotExist: - raise forms.ValidationError("Invalid console server port ({} {})".format( - self.cleaned_data['console_server'], cs_port_name - )) - - return cs_port - - -class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'console_server', 'nullable': 'true'} - ) - ) - console_server = ChainedModelChoiceField( - queryset=Device.objects.filter(device_type__is_console_server=True), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Console Server', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True', - display_field='display_name', - attrs={'filter-for': 'cs_port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Console Server', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='console_server', - ) - ) - cs_port = ChainedModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - chains=( - ('device', 'console_server'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', - disabled_indicator='is_connected', - ) - ) - - class Meta: - model = ConsolePort - fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] - labels = { - 'cs_port': 'Port', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(ConsolePortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") # @@ -1375,97 +1698,41 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF # class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = ConsoleServerPort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class ConsoleServerPortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) + name_pattern = ExpandableNameField( + label='Name' ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) + tags = TagField( + required=False ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=ConsolePort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/console-ports/?device_id={{device}}', - disabled_indicator='is_connected' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) - ) - - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } class ConsoleServerPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -1473,170 +1740,27 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): # class PowerPortForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = PowerPort - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class PowerPortCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class PowerConnectionCSVForm(forms.ModelForm): - pdu = FlexibleModelChoiceField( - queryset=Device.objects.filter(device_type__is_pdu=True), - to_field_name='name', - help_text='PDU name or ID', - error_messages={ - 'invalid_choice': 'PDU not found.', - } + name_pattern = ExpandableNameField( + label='Name' ) - power_outlet = forms.CharField( - help_text='Power outlet name' + tags = TagField( + required=False ) - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found', - } - ) - power_port = forms.CharField( - help_text='Power port name' - ) - connection_status = CSVChoiceField( - choices=CONNECTION_STATUS_CHOICES, - help_text='Connection status' - ) - - class Meta: - model = PowerPort - fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] - - def clean_power_port(self): - - power_port_name = self.cleaned_data.get('power_port') - if not self.cleaned_data.get('device') or not power_port_name: - return None - - try: - # Retrieve power port by name - powerport = PowerPort.objects.get( - device=self.cleaned_data['device'], name=power_port_name - ) - # Check if the power port is already connected - if powerport.power_outlet is not None: - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device'], power_port_name - )) - except PowerPort.DoesNotExist: - raise forms.ValidationError("Invalid power port ({} {})".format( - self.cleaned_data['device'], power_port_name - )) - - self.instance = powerport - return powerport - - def clean_power_outlet(self): - - power_outlet_name = self.cleaned_data.get('power_outlet') - if not self.cleaned_data.get('pdu') or not power_outlet_name: - return None - - try: - # Retrieve power outlet by name - power_outlet = PowerOutlet.objects.get( - device=self.cleaned_data['pdu'], name=power_outlet_name - ) - # Check if the power outlet is already connected - if PowerPort.objects.filter(power_outlet=power_outlet).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['pdu'], power_outlet_name - )) - except PowerOutlet.DoesNotExist: - raise forms.ValidationError("Invalid power outlet ({} {})".format( - self.cleaned_data['pdu'], power_outlet_name - )) - - return power_outlet - - -class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) - ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'pdu', 'nullable': 'true'} - ) - ) - pdu = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='PDU', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True', - display_field='display_name', - attrs={'filter-for': 'power_outlet'} - ) - ) - livesearch = forms.CharField( - required=False, - label='PDU', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='pdu' - ) - ) - power_outlet = ChainedModelChoiceField( - queryset=PowerOutlet.objects.all(), - chains=( - ('device', 'pdu'), - ), - label='Outlet', - widget=APISelect( - api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='is_connected' - ) - ) - - class Meta: - model = PowerPort - fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] - labels = { - 'power_outlet': 'Outlet', - 'connection_status': 'Status', - } - - def __init__(self, *args, **kwargs): - - super(PowerPortConnectionForm, self).__init__(*args, **kwargs) - - if not self.instance.pk: - raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") # @@ -1644,97 +1768,41 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor # class PowerOutletForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = PowerOutlet - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class PowerOutletCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) - - -class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - widget=forms.Select( - attrs={'filter-for': 'rack'} - ) + name_pattern = ExpandableNameField( + label='Name' ) - rack = ChainedModelChoiceField( - queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', - required=False, - widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} - ) + tags = TagField( + required=False ) - device = ChainedModelChoiceField( - queryset=Device.objects.all(), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', - required=False, - widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', - display_field='display_name', - attrs={'filter-for': 'port'} - ) - ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device-list', - field_to_update='device' - ) - ) - port = ChainedModelChoiceField( - queryset=PowerPort.objects.all(), - chains=( - ('device', 'device'), - ), - label='Port', - widget=APISelect( - api_url='/api/dcim/power-ports/?device_id={{device}}', - disabled_indicator='is_connected' - ) - ) - connection_status = forms.BooleanField( - required=False, - initial=CONNECTION_STATUS_CONNECTED, - label='Status', - widget=forms.Select( - choices=CONNECTION_STATUS_CHOICES - ) - ) - - class Meta: - fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] - labels = { - 'connection_status': 'Status', - } class PowerOutletBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) # @@ -1742,7 +1810,9 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Interface @@ -1761,23 +1831,23 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): } def __init__(self, *args, **kwargs): - super(InterfaceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or VC master) if self.is_bound: device = Device.objects.get(pk=self.data['device']) - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG ) else: device = self.instance.device - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG ) def clean(self): - super(InterfaceForm, self).clean() + super().clean() # Validate VLAN assignments tagged_vlans = self.cleaned_data['tagged_vlans'] @@ -1797,7 +1867,11 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): vlans = forms.MultipleChoiceField( choices=[], label='VLANs', - widget=forms.SelectMultiple(attrs={'size': 20}) + widget=forms.SelectMultiple( + attrs={ + 'size': 20, + } + ) ) tagged = forms.BooleanField( required=False, @@ -1810,7 +1884,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): - super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.mode == IFACE_MODE_ACCESS: self.initial['tagged'] = False @@ -1855,7 +1929,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): def clean(self): - super(InterfaceAssignVLANsForm, self).clean() + super().clean() # Only untagged VLANs permitted on an access interface if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: @@ -1873,24 +1947,50 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): else: self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] - return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) + return super().save(*args, **kwargs) class InterfaceCreateForm(ComponentForm, forms.Form): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) - enabled = forms.BooleanField(required=False) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mac_address = forms.CharField(required=False, label='MAC Address') + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=IFACE_FF_CHOICES + ) + enabled = forms.BooleanField( + required=False + ) + lag = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent LAG' + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) mgmt_only = forms.BooleanField( required=False, - label='OOB Management', + label='Management only', help_text='This interface is used only for out-of-band management' ) - description = forms.CharField(max_length=100, required=False) - mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - tags = TagField(required=False) + description = forms.CharField( + max_length=100, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(IFACE_MODE_CHOICES), + required=False + ) + tags = TagField( + required=False + ) def __init__(self, *args, **kwargs): @@ -1898,11 +1998,11 @@ class InterfaceCreateForm(ComponentForm, forms.Form): kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) - super(InterfaceCreateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or its VC master) if self.parent is not None: - self.fields['lag'].queryset = Interface.objects.order_naturally().filter( + self.fields['lag'].queryset = Interface.objects.filter( device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG ) else: @@ -1910,82 +2010,263 @@ class InterfaceCreateForm(ComponentForm, forms.Form): class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) - form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) - enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') - description = forms.CharField(max_length=100, required=False) - mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + form_factor = forms.ChoiceField( + choices=add_blank_choice(IFACE_FF_CHOICES), + required=False + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + lag = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent LAG' + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Management only' + ) + description = forms.CharField( + max_length=100, + required=False + ) + mode = forms.ChoiceField( + choices=add_blank_choice(IFACE_MODE_CHOICES), + required=False + ) class Meta: - nullable_fields = ['lag', 'mtu', 'description', 'mode'] + nullable_fields = [ + 'lag', 'mtu', 'description', 'mode', + ] def __init__(self, *args, **kwargs): - super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit LAG choices to interfaces which belong to the parent device (or VC master) device = self.parent_obj if device is not None: - interface_ordering = device.device_type.interface_ordering - self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter( - device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + form_factor=IFACE_FF_LAG ) else: self.fields['lag'].choices = [] class InterfaceBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) class InterfaceBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) - - -# -# Interface connections -# - -class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): - interface_a = forms.ChoiceField( - choices=[], - widget=SelectWithDisabled, - label='Interface' + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() ) - site_b = forms.ModelChoiceField( + + +# +# Front pass-through ports +# + +class FrontPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = FrontPort + fields = [ + 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic +class FrontPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES + ) + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.' + ) + description = forms.CharField( + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in self.parent.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=self.parent) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def clean(self): + + # Validate that the number of ports being created equals the number of selected (rear port, position) tuples + front_port_count = len(self.cleaned_data['name_pattern']) + rear_port_count = len(self.cleaned_data['rear_port_set']) + if front_port_count != rear_port_count: + raise forms.ValidationError({ + 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' + 'were selected. These counts must match.'.format(front_port_count, rear_port_count) + }) + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class FrontPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class FrontPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +# +# Rear pass-through ports +# + +class RearPortForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = RearPort + fields = [ + 'device', 'name', 'type', 'positions', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class RearPortCreateForm(ComponentForm): + name_pattern = ExpandableNameField( + label='Name' + ) + type = forms.ChoiceField( + choices=PORT_TYPE_CHOICES + ) + positions = forms.IntegerField( + min_value=1, + max_value=64, + initial=1, + help_text='The number of front ports which may be mapped to each rear port' + ) + description = forms.CharField( + required=False + ) + + +class RearPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class RearPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=RearPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +# +# Cables +# + +class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, widget=forms.Select( - attrs={'filter-for': 'rack_b'} + attrs={ + 'filter-for': 'termination_b_rack', + } ) ) - rack_b = ChainedModelChoiceField( + termination_b_rack = ChainedModelChoiceField( queryset=Rack.objects.all(), chains=( - ('site', 'site_b'), + ('site', 'termination_b_site'), ), label='Rack', required=False, widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site_b}}', - attrs={'filter-for': 'device_b', 'nullable': 'true'} + api_url='/api/dcim/racks/?site_id={{termination_b_site}}', + attrs={ + 'filter-for': 'termination_b_device', + 'nullable': 'true', + } ) ) - device_b = ChainedModelChoiceField( + termination_b_device = ChainedModelChoiceField( queryset=Device.objects.all(), chains=( - ('site', 'site_b'), - ('rack', 'rack_b'), + ('site', 'termination_b_site'), + ('rack', 'termination_b_rack'), ), label='Device', required=False, widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}', + api_url='/api/dcim/devices/?site_id={{termination_b_site}}&rack_id={{termination_b_rack}}', display_field='display_name', - attrs={'filter-for': 'interface_b'} + attrs={ + 'filter-for': 'termination_b_id', + } ) ) livesearch = forms.CharField( @@ -1994,121 +2275,255 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor widget=Livesearch( query_key='q', query_url='dcim-api:device-list', - field_to_update='device_b' + field_to_update='termination_b_device' ) ) - interface_b = ChainedModelChoiceField( - queryset=Interface.objects.connectable().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ), - chains=( - ('device', 'device_b'), - ), - label='Interface', + termination_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + label='Type', + widget=ContentTypeSelect( + attrs={ + 'filter-for': 'termination_b_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Name', widget=APISelect( - api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', - disabled_indicator='is_connected' + api_url='/api/dcim/{{termination_b_type}}s/?device_id={{termination_b_device}}', + disabled_indicator='cable', + url_conditional_append={ + 'termination_b_type__interface': '&type=physical', + } ) ) class Meta: - model = InterfaceConnection - fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status'] - - def __init__(self, device_a, *args, **kwargs): - - super(InterfaceConnectionForm, self).__init__(*args, **kwargs) - - # Initialize interface A choices - device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - self.fields['interface_a'].choices = [ - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces + model = Cable + fields = [ + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'livesearch', 'termination_b_type', + 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] - # Mark connected interfaces as disabled - if self.data.get('device_b'): - self.fields['interface_b'].choices = [] - for iface in self.fields['interface_b'].queryset: - self.fields['interface_b'].choices.append( - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) - ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Define available types for endpoint B based on the type of endpoint A + termination_a_type = self.instance.termination_a._meta.model_name + self.fields['termination_b_type'].queryset = ContentType.objects.filter( + model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type) + ).exclude( + model='circuittermination' + ) -class InterfaceConnectionCSVForm(forms.ModelForm): - device_a = FlexibleModelChoiceField( +class CableForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = Cable + fields = [ + 'type', 'status', 'label', 'color', 'length', 'length_unit', + ] + + +class CableCSVForm(forms.ModelForm): + + # Termination A + side_a_device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Name or ID of device A', - error_messages={'invalid_choice': 'Device A not found.'} + help_text='Side A device name or ID', + error_messages={ + 'invalid_choice': 'Side A device not found', + } ) - interface_a = forms.CharField( - help_text='Name of interface A' + side_a_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={ + 'model__in': CABLE_TERMINATION_TYPES, + }, + to_field_name='model', + help_text='Side A type' ) - device_b = FlexibleModelChoiceField( + side_a_name = forms.CharField( + help_text='Side A component' + ) + + # Termination B + side_b_device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Name or ID of device B', - error_messages={'invalid_choice': 'Device B not found.'} + help_text='Side B device name or ID', + error_messages={ + 'invalid_choice': 'Side B device not found', + } ) - interface_b = forms.CharField( - help_text='Name of interface B' + side_b_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to={ + 'model__in': CABLE_TERMINATION_TYPES, + }, + to_field_name='model', + help_text='Side B type' ) - connection_status = CSVChoiceField( + side_b_name = forms.CharField( + help_text='Side B component' + ) + + # Cable attributes + status = CSVChoiceField( choices=CONNECTION_STATUS_CHOICES, + required=False, help_text='Connection status' ) + type = CSVChoiceField( + choices=CABLE_TYPE_CHOICES, + required=False, + help_text='Cable type' + ) + length_unit = CSVChoiceField( + choices=CABLE_LENGTH_UNIT_CHOICES, + required=False, + help_text='Length unit' + ) class Meta: - model = InterfaceConnection - fields = InterfaceConnection.csv_headers + model = Cable + fields = [ + 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', + 'status', 'label', 'color', 'length', 'length_unit', + ] + help_texts = { + 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + } - def clean_interface_a(self): + # TODO: Merge the clean() methods for either end + def clean_side_a_name(self): - interface_name = self.cleaned_data.get('interface_a') - if not interface_name: + device = self.cleaned_data.get('side_a_device') + content_type = self.cleaned_data.get('side_a_type') + name = self.cleaned_data.get('side_a_name') + if not device or not content_type or not name: return None + model = content_type.model_class() try: - # Retrieve interface by name - interface = Interface.objects.get( - device=self.cleaned_data['device_a'], name=interface_name + termination_object = model.objects.get( + device=device, + name=name + ) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side A: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "A side termination not found: {} {}".format(device, name) ) - # Check for an existing connection to this interface - if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device_a'], interface_name - )) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})".format( - self.cleaned_data['device_a'], interface_name - )) - return interface + self.instance.termination_a = termination_object + return termination_object - def clean_interface_b(self): + def clean_side_b_name(self): - interface_name = self.cleaned_data.get('interface_b') - if not interface_name: + device = self.cleaned_data.get('side_b_device') + content_type = self.cleaned_data.get('side_b_type') + name = self.cleaned_data.get('side_b_name') + if not device or not content_type or not name: return None + model = content_type.model_class() try: - # Retrieve interface by name - interface = Interface.objects.get( - device=self.cleaned_data['device_b'], name=interface_name + termination_object = model.objects.get( + device=device, + name=name + ) + if termination_object.cable is not None: + raise forms.ValidationError( + "Side B: {} {} is already connected".format(device, termination_object) + ) + except ObjectDoesNotExist: + raise forms.ValidationError( + "B side termination not found: {} {}".format(device, name) ) - # Check for an existing connection to this interface - if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): - raise forms.ValidationError("{} {} is already connected".format( - self.cleaned_data['device_b'], interface_name - )) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})".format( - self.cleaned_data['device_b'], interface_name - )) - return interface + self.instance.termination_b = termination_object + return termination_object + + def clean_length_unit(self): + # Avoid trying to save as NULL + length_unit = self.cleaned_data.get('length_unit', None) + return length_unit if length_unit is not None else '' + + +class CableBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cable.objects.all(), + widget=forms.MultipleHiddenInput + ) + type = forms.ChoiceField( + choices=add_blank_choice(CABLE_TYPE_CHOICES), + required=False, + initial='' + ) + status = forms.ChoiceField( + choices=add_blank_choice(CONNECTION_STATUS_CHOICES), + required=False, + initial='' + ) + label = forms.CharField( + max_length=100, + required=False + ) + color = forms.CharField( + max_length=6, + required=False, + widget=ColorSelect() + ) + length = forms.IntegerField( + min_value=1, + required=False + ) + length_unit = forms.ChoiceField( + choices=add_blank_choice(CABLE_LENGTH_UNIT_CHOICES), + required=False, + initial='' + ) + + class Meta: + nullable_fields = [ + 'type', 'status', 'label', 'color', 'length', + ] + + def clean(self): + + # Validate length/unit + length = self.cleaned_data.get('length') + length_unit = self.cleaned_data.get('length_unit') + if length and not length_unit: + raise forms.ValidationError({ + 'length_unit': "Must specify a unit when setting length" + }) + + +class CableFilterForm(BootstrapMixin, forms.Form): + model = Cable + q = forms.CharField( + required=False, + label='Search' + ) + type = AnnotatedMultipleChoiceField( + choices=CABLE_TYPE_CHOICES, + annotate=Cable.objects.all(), + annotate_field='type', + required=False + ) + color = AnnotatedMultipleChoiceField( + choices=COLOR_CHOICES, + annotate=Cable.objects.all(), + annotate_field='color', + required=False + ) # @@ -2116,19 +2531,27 @@ class InterfaceConnectionCSVForm(forms.ModelForm): # class DeviceBayForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = DeviceBay - fields = ['device', 'name', 'tags'] + fields = [ + 'device', 'name', 'tags', + ] widgets = { 'device': forms.HiddenInput(), } class DeviceBayCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - tags = TagField(required=False) + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) class PopulateDeviceBayForm(BootstrapMixin, forms.Form): @@ -2140,7 +2563,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): def __init__(self, device_bay, *args, **kwargs): - super(PopulateDeviceBayForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['installed_device'].queryset = Device.objects.filter( site=device_bay.device.site, @@ -2152,7 +2575,10 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -2160,18 +2586,39 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') - device = forms.CharField(required=False, label='Device name') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='slug' + ) + device = forms.CharField( + required=False, + label='Device name' + ) # @@ -2179,11 +2626,15 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = InventoryItem - fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags'] + fields = [ + 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', + ] class InventoryItemCSVForm(forms.ModelForm): @@ -2211,21 +2662,44 @@ class InventoryItemCSVForm(forms.ModelForm): class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput) - manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) - part_id = forms.CharField(max_length=50, required=False, label='Part ID') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItem.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_id = forms.CharField( + max_length=50, + required=False, + label='Part ID' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['manufacturer', 'part_id', 'description'] + nullable_fields = [ + 'manufacturer', 'part_id', 'description', + ] class InventoryItemFilterForm(BootstrapMixin, forms.Form): model = InventoryItem - q = forms.CharField(required=False, label='Search') - device = forms.CharField(required=False, label='Device name') + q = forms.CharField( + required=False, + label='Search' + ) + device = forms.CharField( + required=False, + label='Device name' + ) manufacturer = FilterChoiceField( - queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')), + queryset=Manufacturer.objects.annotate( + filter_count=Count('inventory_items') + ), to_field_name='slug', null_label='-- None --' ) @@ -2236,24 +2710,31 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): # class DeviceSelectionForm(forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) class VirtualChassisForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = VirtualChassis - fields = ['master', 'domain', 'tags'] + fields = [ + 'master', 'domain', 'tags', + ] widgets = { - 'master': SelectWithPK, + 'master': SelectWithPK(), } class BaseVCMemberFormSet(forms.BaseModelFormSet): def clean(self): - super(BaseVCMemberFormSet, self).clean() + super().clean() # Check for duplicate VC position values vc_position_list = [] @@ -2270,14 +2751,16 @@ class DeviceVCMembershipForm(forms.ModelForm): class Meta: model = Device - fields = ['vc_position', 'vc_priority'] + fields = [ + 'vc_position', 'vc_priority', + ] labels = { 'vc_position': 'Position', 'vc_priority': 'Priority', } def __init__(self, validate_vc_position=False, *args, **kwargs): - super(DeviceVCMembershipForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Require VC position (only required when the Device is a VirtualChassis member) self.fields['vc_position'].required = True @@ -2308,7 +2791,9 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): label='Site', required=False, widget=forms.Select( - attrs={'filter-for': 'rack'} + attrs={ + 'filter-for': 'rack', + } ) ) rack = ChainedModelChoiceField( @@ -2320,11 +2805,16 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): required=False, widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device', 'nullable': 'true'} + attrs={ + 'filter-for': 'device', + 'nullable': 'true', + } ) ) device = ChainedModelChoiceField( - queryset=Device.objects.filter(virtual_chassis__isnull=True), + queryset=Device.objects.filter( + virtual_chassis__isnull=True + ), chains=( ('site', 'site'), ('rack', 'rack'), @@ -2340,13 +2830,18 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): def clean_device(self): device = self.cleaned_data['device'] if device.virtual_chassis is not None: - raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device)) + raise forms.ValidationError( + "Device {} is already assigned to a virtual chassis.".format(device) + ) return device class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualChassis - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py new file mode 100644 index 000000000..52df1afe8 --- /dev/null +++ b/netbox/dcim/managers.py @@ -0,0 +1,85 @@ +from django.db.models import Manager, QuerySet +from django.db.models.expressions import RawSQL + +from .constants import NONCONNECTABLE_IFACE_TYPES + +# Regular expressions for parsing Interface names +TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')" +SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)" +SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)" +POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)" +SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)" +ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)" +CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" +VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" + + +class DeviceComponentManager(Manager): + + def get_queryset(self): + + queryset = super().get_queryset() + table_name = self.model._meta.db_table + sql = r"CONCAT(REGEXP_REPLACE({}.name, '\d+$', ''), LPAD(SUBSTRING({}.name FROM '\d+$'), 8, '0'))" + + # Pad any trailing digits to effect natural sorting + return queryset.extra( + select={ + 'name_padded': sql.format(table_name, table_name), + } + ).order_by('name_padded') + + +class InterfaceQuerySet(QuerySet): + + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + + +class InterfaceManager(Manager): + + def get_queryset(self): + """ + Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field + is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel, + and virtual circuit: + + {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc} + + Components absent from the interface name are coalesced to zero or null. For example, an interface named + GigabitEthernet1/2/3 would be parsed as follows: + + type = 'GigabitEthernet' + slot = 1 + subslot = 2 + position = 3 + subposition = None + id = None + channel = 0 + vc = 0 + + The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not + match any of the prescribed fields. + """ + + sql_col = '{}.name'.format(self.model._meta.db_table) + ordering = [ + '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', + ] + + fields = { + '_type': RawSQL(TYPE_RE.format(sql_col), []), + '_id': RawSQL(ID_RE.format(sql_col), []), + '_slot': RawSQL(SLOT_RE.format(sql_col), []), + '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), + '_position': RawSQL(POSITION_RE.format(sql_col), []), + '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), + '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), + '_vc': RawSQL(VC_RE.format(sql_col), []), + } + + return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering) diff --git a/netbox/dcim/migrations/0001_initial.py b/netbox/dcim/migrations/0001_initial.py index da18bdbfe..db5f3faf2 100644 --- a/netbox/dcim/migrations/0001_initial.py +++ b/netbox/dcim/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0002_auto_20160622_1821.py b/netbox/dcim/migrations/0002_auto_20160622_1821.py index e269d43f4..1e3aa4d2a 100644 --- a/netbox/dcim/migrations/0002_auto_20160622_1821.py +++ b/netbox/dcim/migrations/0002_auto_20160622_1821.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py index a641c3a2f..c3412cf10 100644 --- a/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py +++ b/netbox/dcim/migrations/0002_auto_20160622_1821_squashed_0022_color_names_to_rgb.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:06 -from __future__ import unicode_literals - import dcim.fields import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0003_auto_20160628_1721.py b/netbox/dcim/migrations/0003_auto_20160628_1721.py index deebc8518..312d0456c 100644 --- a/netbox/dcim/migrations/0003_auto_20160628_1721.py +++ b/netbox/dcim/migrations/0003_auto_20160628_1721.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-28 17:21 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0004_auto_20160701_2049.py b/netbox/dcim/migrations/0004_auto_20160701_2049.py index e051daded..0806acb82 100644 --- a/netbox/dcim/migrations/0004_auto_20160701_2049.py +++ b/netbox/dcim/migrations/0004_auto_20160701_2049.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-01 20:49 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0005_auto_20160706_1722.py b/netbox/dcim/migrations/0005_auto_20160706_1722.py index 83a5cf7cb..a286d6ff3 100644 --- a/netbox/dcim/migrations/0005_auto_20160706_1722.py +++ b/netbox/dcim/migrations/0005_auto_20160706_1722.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-06 17:22 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations, models diff --git a/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py b/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py index 670a174f9..6038cc027 100644 --- a/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py +++ b/netbox/dcim/migrations/0006_add_device_primary_ip4_ip6.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 18:40 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0007_device_copy_primary_ip.py b/netbox/dcim/migrations/0007_device_copy_primary_ip.py index 055eac7d0..0d53337f7 100644 --- a/netbox/dcim/migrations/0007_device_copy_primary_ip.py +++ b/netbox/dcim/migrations/0007_device_copy_primary_ip.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 18:40 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0008_device_remove_primary_ip.py b/netbox/dcim/migrations/0008_device_remove_primary_ip.py index 91465e878..f43452de2 100644 --- a/netbox/dcim/migrations/0008_device_remove_primary_ip.py +++ b/netbox/dcim/migrations/0008_device_remove_primary_ip.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-11 19:01 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0009_site_32bit_asn_support.py b/netbox/dcim/migrations/0009_site_32bit_asn_support.py index c93340cea..0a72a6cf4 100644 --- a/netbox/dcim/migrations/0009_site_32bit_asn_support.py +++ b/netbox/dcim/migrations/0009_site_32bit_asn_support.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-13 19:24 -from __future__ import unicode_literals - import dcim.fields from django.db import migrations diff --git a/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py index bf2f31c57..769a6f678 100644 --- a/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py +++ b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-14 21:38 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0011_devicetype_part_number.py b/netbox/dcim/migrations/0011_devicetype_part_number.py index 62c97abc6..eb77ea500 100644 --- a/netbox/dcim/migrations/0011_devicetype_part_number.py +++ b/netbox/dcim/migrations/0011_devicetype_part_number.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 15:05 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py index 8dcf8f81a..b01f507c3 100644 --- a/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py +++ b/netbox/dcim/migrations/0012_site_rack_device_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:59 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0013_add_interface_form_factors.py b/netbox/dcim/migrations/0013_add_interface_form_factors.py index 310eb1eb6..478cb59ff 100644 --- a/netbox/dcim/migrations/0013_add_interface_form_factors.py +++ b/netbox/dcim/migrations/0013_add_interface_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-06 20:24 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0014_rack_add_type_width.py b/netbox/dcim/migrations/0014_rack_add_type_width.py index c14768c0f..a3922c8cd 100644 --- a/netbox/dcim/migrations/0014_rack_add_type_width.py +++ b/netbox/dcim/migrations/0014_rack_add_type_width.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-08 21:11 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0015_rack_add_u_height_validator.py b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py index 8e555204b..167dd8f54 100644 --- a/netbox/dcim/migrations/0015_rack_add_u_height_validator.py +++ b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-09 21:18 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0016_module_add_manufacturer.py b/netbox/dcim/migrations/0016_module_add_manufacturer.py index 6a2264a83..7204e6626 100644 --- a/netbox/dcim/migrations/0016_module_add_manufacturer.py +++ b/netbox/dcim/migrations/0016_module_add_manufacturer.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-10 13:45 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0017_rack_add_role.py b/netbox/dcim/migrations/0017_rack_add_role.py index eb3560b37..48500f4b4 100644 --- a/netbox/dcim/migrations/0017_rack_add_role.py +++ b/netbox/dcim/migrations/0017_rack_add_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-10 14:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0018_device_add_asset_tag.py b/netbox/dcim/migrations/0018_device_add_asset_tag.py index 706b42ac4..84d1cef35 100644 --- a/netbox/dcim/migrations/0018_device_add_asset_tag.py +++ b/netbox/dcim/migrations/0018_device_add_asset_tag.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-08-11 15:42 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0019_new_iface_form_factors.py b/netbox/dcim/migrations/0019_new_iface_form_factors.py index b2358ba5e..b2d8be533 100644 --- a/netbox/dcim/migrations/0019_new_iface_form_factors.py +++ b/netbox/dcim/migrations/0019_new_iface_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-13 15:20 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0020_rack_desc_units.py b/netbox/dcim/migrations/0020_rack_desc_units.py index d5a74706d..7408c82ef 100644 --- a/netbox/dcim/migrations/0020_rack_desc_units.py +++ b/netbox/dcim/migrations/0020_rack_desc_units.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-28 15:01 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0021_add_ff_flexstack.py b/netbox/dcim/migrations/0021_add_ff_flexstack.py index 9e85ac909..bb4c4f4be 100644 --- a/netbox/dcim/migrations/0021_add_ff_flexstack.py +++ b/netbox/dcim/migrations/0021_add_ff_flexstack.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-31 18:47 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0022_color_names_to_rgb.py b/netbox/dcim/migrations/0022_color_names_to_rgb.py index 97e5de9ca..87fba4787 100644 --- a/netbox/dcim/migrations/0022_color_names_to_rgb.py +++ b/netbox/dcim/migrations/0022_color_names_to_rgb.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-06 16:35 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0023_devicetype_comments.py b/netbox/dcim/migrations/0023_devicetype_comments.py index 677a8af9d..5f70e8076 100644 --- a/netbox/dcim/migrations/0023_devicetype_comments.py +++ b/netbox/dcim/migrations/0023_devicetype_comments.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-16 16:08 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py index a613552ad..4d4cfb603 100644 --- a/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py +++ b/netbox/dcim/migrations/0023_devicetype_comments_squashed_0043_device_component_name_lengths.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:13 -from __future__ import unicode_literals - import dcim.fields from django.conf import settings import django.contrib.postgres.fields diff --git a/netbox/dcim/migrations/0024_site_add_contact_fields.py b/netbox/dcim/migrations/0024_site_add_contact_fields.py index 34e17561f..218107ba2 100644 --- a/netbox/dcim/migrations/0024_site_add_contact_fields.py +++ b/netbox/dcim/migrations/0024_site_add_contact_fields.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2016-12-29 16:23 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py index d1263cb89..56db88f1c 100644 --- a/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py +++ b/netbox/dcim/migrations/0025_devicetype_add_interface_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-06 16:56 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0026_add_rack_reservations.py b/netbox/dcim/migrations/0026_add_rack_reservations.py index b9d4f8214..ba66feea5 100644 --- a/netbox/dcim/migrations/0026_add_rack_reservations.py +++ b/netbox/dcim/migrations/0026_add_rack_reservations.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 18:43 -from __future__ import unicode_literals - from django.conf import settings import django.contrib.postgres.fields from django.db import migrations, models diff --git a/netbox/dcim/migrations/0027_device_add_site.py b/netbox/dcim/migrations/0027_device_add_site.py index 12d85f53e..bef85a822 100644 --- a/netbox/dcim/migrations/0027_device_add_site.py +++ b/netbox/dcim/migrations/0027_device_add_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:21 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py index 6e7c52114..a67f34b38 100644 --- a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py +++ b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:23 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0029_allow_rackless_devices.py b/netbox/dcim/migrations/0029_allow_rackless_devices.py index 83906fc76..dd9f30bf2 100644 --- a/netbox/dcim/migrations/0029_allow_rackless_devices.py +++ b/netbox/dcim/migrations/0029_allow_rackless_devices.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-16 21:25 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0030_interface_add_lag.py b/netbox/dcim/migrations/0030_interface_add_lag.py index 6f5be67a4..1ffd74f04 100644 --- a/netbox/dcim/migrations/0030_interface_add_lag.py +++ b/netbox/dcim/migrations/0030_interface_add_lag.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-27 19:55 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0031_regions.py b/netbox/dcim/migrations/0031_regions.py index d4fd4db5e..73bb77b3f 100644 --- a/netbox/dcim/migrations/0031_regions.py +++ b/netbox/dcim/migrations/0031_regions.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-28 17:14 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import mptt.fields diff --git a/netbox/dcim/migrations/0032_device_increase_name_length.py b/netbox/dcim/migrations/0032_device_increase_name_length.py index e11e75bab..ff0cd137f 100644 --- a/netbox/dcim/migrations/0032_device_increase_name_length.py +++ b/netbox/dcim/migrations/0032_device_increase_name_length.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-03-02 15:09 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py index b327bad12..567de4345 100644 --- a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py +++ b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-17 18:39 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py index ff430c067..db2f0577a 100644 --- a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py +++ b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-21 14:55 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0035_device_expand_status_choices.py b/netbox/dcim/migrations/0035_device_expand_status_choices.py index 16ea807c9..a6f7aa563 100644 --- a/netbox/dcim/migrations/0035_device_expand_status_choices.py +++ b/netbox/dcim/migrations/0035_device_expand_status_choices.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.7 on 2017-05-08 15:57 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py index ac0f89f41..ceed22638 100644 --- a/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py +++ b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-05-09 16:00 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0037_unicode_literals.py b/netbox/dcim/migrations/0037_unicode_literals.py index cba05becc..57ad7a744 100644 --- a/netbox/dcim/migrations/0037_unicode_literals.py +++ b/netbox/dcim/migrations/0037_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import dcim.fields import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0038_wireless_interfaces.py b/netbox/dcim/migrations/0038_wireless_interfaces.py index 61cdb3996..78ea103e5 100644 --- a/netbox/dcim/migrations/0038_wireless_interfaces.py +++ b/netbox/dcim/migrations/0038_wireless_interfaces.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-16 21:38 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py index 4cc7e9616..c5f8dc83d 100644 --- a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py +++ b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-23 17:05 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py index c7d49fe2c..aaca23ea8 100644 --- a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py +++ b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-06-23 20:44 -from __future__ import unicode_literals - from django.db import migrations, models import utilities.fields diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py index 73ca8f3ee..50c2fbd99 100644 --- a/netbox/dcim/migrations/0041_napalm_integration.py +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-07-14 17:26 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py b/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py index 77bea6bc6..e667d9451 100644 --- a/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py +++ b/netbox/dcim/migrations/0042_interface_ff_10ge_cx4.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-29 21:00 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0043_device_component_name_lengths.py b/netbox/dcim/migrations/0043_device_component_name_lengths.py index a52f50859..9f0ba2243 100644 --- a/netbox/dcim/migrations/0043_device_component_name_lengths.py +++ b/netbox/dcim/migrations/0043_device_component_name_lengths.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-29 21:26 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0044_virtualization.py b/netbox/dcim/migrations/0044_virtualization.py index b1e250bc2..362979aef 100644 --- a/netbox/dcim/migrations/0044_virtualization.py +++ b/netbox/dcim/migrations/0044_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 14:15 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py index 42fc5f317..78b4e3a41 100644 --- a/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py +++ b/netbox/dcim/migrations/0044_virtualization_squashed_0055_virtualchassis_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:17 -from __future__ import unicode_literals - from django.conf import settings import django.core.validators from django.db import migrations, models diff --git a/netbox/dcim/migrations/0045_devicerole_vm_role.py b/netbox/dcim/migrations/0045_devicerole_vm_role.py index 775effaf2..306a5a806 100644 --- a/netbox/dcim/migrations/0045_devicerole_vm_role.py +++ b/netbox/dcim/migrations/0045_devicerole_vm_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-29 16:09 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py index d04006524..f6e93a43d 100644 --- a/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py +++ b/netbox/dcim/migrations/0046_rack_lengthen_facility_id.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 17:43 -from __future__ import unicode_literals - from django.db import migrations import utilities.fields diff --git a/netbox/dcim/migrations/0047_more_100ge_form_factors.py b/netbox/dcim/migrations/0047_more_100ge_form_factors.py index dafa81a54..a76ef6c8d 100644 --- a/netbox/dcim/migrations/0047_more_100ge_form_factors.py +++ b/netbox/dcim/migrations/0047_more_100ge_form_factors.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 18:43 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0048_rack_serial.py b/netbox/dcim/migrations/0048_rack_serial.py index 8e060c865..3fb7c0d2e 100644 --- a/netbox/dcim/migrations/0048_rack_serial.py +++ b/netbox/dcim/migrations/0048_rack_serial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 18:50 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0049_rackreservation_change_user.py b/netbox/dcim/migrations/0049_rackreservation_change_user.py index ae9f95246..2d03db587 100644 --- a/netbox/dcim/migrations/0049_rackreservation_change_user.py +++ b/netbox/dcim/migrations/0049_rackreservation_change_user.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-10-31 17:32 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0050_interface_vlan_tagging.py b/netbox/dcim/migrations/0050_interface_vlan_tagging.py index 1906b9179..8acaf4eec 100644 --- a/netbox/dcim/migrations/0050_interface_vlan_tagging.py +++ b/netbox/dcim/migrations/0050_interface_vlan_tagging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-10 20:10 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0051_rackreservation_tenant.py b/netbox/dcim/migrations/0051_rackreservation_tenant.py index 90a551eb8..ca0513ab0 100644 --- a/netbox/dcim/migrations/0051_rackreservation_tenant.py +++ b/netbox/dcim/migrations/0051_rackreservation_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-15 18:56 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py index 334f60ca7..56777744c 100644 --- a/netbox/dcim/migrations/0052_virtual_chassis.py +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-11-27 17:27 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0053_platform_manufacturer.py b/netbox/dcim/migrations/0053_platform_manufacturer.py index 62797716e..bb5f24c91 100644 --- a/netbox/dcim/migrations/0053_platform_manufacturer.py +++ b/netbox/dcim/migrations/0053_platform_manufacturer.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2017-12-19 20:56 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0054_site_status_timezone_description.py b/netbox/dcim/migrations/0054_site_status_timezone_description.py index 723f61fc8..554bf554c 100644 --- a/netbox/dcim/migrations/0054_site_status_timezone_description.py +++ b/netbox/dcim/migrations/0054_site_status_timezone_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.6 on 2018-01-25 18:21 -from __future__ import unicode_literals - from django.db import migrations, models import timezone_field.fields diff --git a/netbox/dcim/migrations/0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0055_virtualchassis_ordering.py index 51cda0ff6..ab23f403f 100644 --- a/netbox/dcim/migrations/0055_virtualchassis_ordering.py +++ b/netbox/dcim/migrations/0055_virtualchassis_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-21 14:41 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/dcim/migrations/0057_tags.py b/netbox/dcim/migrations/0057_tags.py index b0cccfdf3..44ed09497 100644 --- a/netbox/dcim/migrations/0057_tags.py +++ b/netbox/dcim/migrations/0057_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py index e4974be2f..9676e973d 100644 --- a/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py +++ b/netbox/dcim/migrations/0058_relax_rack_naming_constraints.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:27 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/dcim/migrations/0059_site_latitude_longitude.py b/netbox/dcim/migrations/0059_site_latitude_longitude.py index 15e666f35..7c019ed5d 100644 --- a/netbox/dcim/migrations/0059_site_latitude_longitude.py +++ b/netbox/dcim/migrations/0059_site_latitude_longitude.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-21 18:45 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0060_change_logging.py b/netbox/dcim/migrations/0060_change_logging.py index 8a40f4e4e..12a9f95ad 100644 --- a/netbox/dcim/migrations/0060_change_logging.py +++ b/netbox/dcim/migrations/0060_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:14 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/dcim/migrations/0064_remove_platform_rpc_client.py b/netbox/dcim/migrations/0064_remove_platform_rpc_client.py new file mode 100644 index 000000000..4926c4b32 --- /dev/null +++ b/netbox/dcim/migrations/0064_remove_platform_rpc_client.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.8 on 2018-08-22 16:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0063_device_local_context_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='platform', + name='rpc_client', + ), + ] diff --git a/netbox/dcim/migrations/0065_front_rear_ports.py b/netbox/dcim/migrations/0065_front_rear_ports.py new file mode 100644 index 000000000..a7fe9eab9 --- /dev/null +++ b/netbox/dcim/migrations/0065_front_rear_ports.py @@ -0,0 +1,131 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0002_auto_20150616_2121'), + ('dcim', '0064_remove_platform_rpc_client'), + ] + + operations = [ + migrations.CreateModel( + name='FrontPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='FrontPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('rear_port_position', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='RearPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.Device')), + ('tags', taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='RearPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('type', models.PositiveSmallIntegerField()), + ('positions', models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(64)])), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.AddField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearport_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontport_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='rear_port', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontport_templates', to='dcim.RearPortTemplate'), + ), + migrations.AddField( + model_name='frontport', + name='rear_port', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.RearPort'), + ), + migrations.AddField( + model_name='frontport', + name='tags', + field=taggit.managers.TaggableManager(through='taggit.TaggedItem', to='taggit.Tag'), + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearport', + unique_together={('device', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('rear_port', 'rear_port_position'), ('device_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontport', + unique_together={('device', 'name'), ('rear_port', 'rear_port_position')}, + ), + + # Rename reverse relationships of component templates to DeviceType + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleport_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverport_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlet_templates', to='dcim.DeviceType'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerport_templates', to='dcim.DeviceType'), + ), + ] diff --git a/netbox/dcim/migrations/0066_cables.py b/netbox/dcim/migrations/0066_cables.py new file mode 100644 index 000000000..253167392 --- /dev/null +++ b/netbox/dcim/migrations/0066_cables.py @@ -0,0 +1,322 @@ +import sys + +from django.db import migrations, models +import django.db.models.deletion + +import utilities.fields + + +def console_connections_to_cables(apps, schema_editor): + """ + Copy all existing console connections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + ConsolePort = apps.get_model('dcim', 'ConsolePort') + ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + consoleport_type = ContentType.objects.get_for_model(ConsolePort) + consoleserverport_type = ContentType.objects.get_for_model(ConsoleServerPort) + + # Create a new Cable instance from each console connection + if 'test' not in sys.argv: + print("\n Adding console connections... ", end='', flush=True) + for consoleport in ConsolePort.objects.filter(connected_endpoint__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=consoleport_type, + termination_a_id=consoleport.id, + termination_b_type=consoleserverport_type, + termination_b_id=consoleport.connected_endpoint_id, + status=consoleport.connection_status + ) + + # Cache the Cable on its two termination points + ConsolePort.objects.filter(pk=consoleport.id).update( + cable=cable + ) + ConsoleServerPort.objects.filter(pk=consoleport.connected_endpoint_id).update( + connection_status=consoleport.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=consoleport_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + # Normalize connection_status for all non-connected ConsolePorts + ConsolePort.objects.filter(connected_endpoint__isnull=True).update(connection_status=None) + + +def power_connections_to_cables(apps, schema_editor): + """ + Copy all existing power connections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + PowerPort = apps.get_model('dcim', 'PowerPort') + PowerOutlet = apps.get_model('dcim', 'PowerOutlet') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + powerport_type = ContentType.objects.get_for_model(PowerPort) + poweroutlet_type = ContentType.objects.get_for_model(PowerOutlet) + + # Create a new Cable instance from each power connection + if 'test' not in sys.argv: + print(" Adding power connections... ", end='', flush=True) + for powerport in PowerPort.objects.filter(connected_endpoint__isnull=False): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=powerport_type, + termination_a_id=powerport.id, + termination_b_type=poweroutlet_type, + termination_b_id=powerport.connected_endpoint_id, + status=powerport.connection_status + ) + + # Cache the Cable on its two termination points + PowerPort.objects.filter(pk=powerport.id).update( + cable=cable + ) + PowerOutlet.objects.filter(pk=powerport.connected_endpoint_id).update( + connection_status=powerport.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=powerport_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + # Normalize connection_status for all non-connected PowerPorts + PowerPort.objects.filter(connected_endpoint__isnull=True).update(connection_status=None) + + +def interface_connections_to_cables(apps, schema_editor): + """ + Copy all InterfaceConnections as Cables + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Interface = apps.get_model('dcim', 'Interface') + InterfaceConnection = apps.get_model('dcim', 'InterfaceConnection') + Cable = apps.get_model('dcim', 'Cable') + + # Load content types + interface_type = ContentType.objects.get_for_model(Interface) + + # Create a new Cable instance from each InterfaceConnection + if 'test' not in sys.argv: + print(" Adding interface connections... ", end='', flush=True) + for conn in InterfaceConnection.objects.all(): + + # Create the new Cable + cable = Cable.objects.create( + termination_a_type=interface_type, + termination_a_id=conn.interface_a_id, + termination_b_type=interface_type, + termination_b_id=conn.interface_b_id, + status=conn.connection_status + ) + + # Cache the connected Cable on each Interface + Interface.objects.filter(pk=conn.interface_a_id).update( + _connected_interface=conn.interface_b, + connection_status=conn.connection_status, + cable=cable + ) + Interface.objects.filter(pk=conn.interface_b_id).update( + _connected_interface=conn.interface_a, + connection_status=conn.connection_status, + cable=cable + ) + + cable_count = Cable.objects.filter(termination_a_type=interface_type).count() + if 'test' not in sys.argv: + print("{} cables created".format(cable_count)) + + +def delete_interfaceconnection_content_type(apps, schema_editor): + """ + Delete the ContentType for the InterfaceConnection model. (This is not done automatically upon model deletion.) + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + InterfaceConnection = apps.get_model('dcim', 'InterfaceConnection') + ContentType.objects.get_for_model(InterfaceConnection).delete() + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('circuits', '0006_terminations'), + ('dcim', '0065_front_rear_ports'), + ] + + operations = [ + + # Create the Cable model + migrations.CreateModel( + name='Cable', + options={'ordering': ['pk']}, + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('termination_a_id', models.PositiveIntegerField()), + ('termination_b_id', models.PositiveIntegerField()), + ('type', models.PositiveSmallIntegerField(blank=True, null=True)), + ('status', models.BooleanField(default=True)), + ('label', models.CharField(blank=True, max_length=100)), + ('color', utilities.fields.ColorField(blank=True, max_length=6)), + ('length', models.PositiveSmallIntegerField(blank=True, null=True)), + ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)), + ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)), + ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ], + ), + migrations.AlterUniqueTogether( + name='cable', + unique_together={('termination_b_type', 'termination_b_id'), ('termination_a_type', 'termination_a_id')}, + ), + + # Alter console port models + migrations.RenameField( + model_name='consoleport', + old_name='cs_port', + new_name='connected_endpoint' + ), + migrations.AlterField( + model_name='consoleport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.Device'), + ), + migrations.AlterField( + model_name='consoleport', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.ConsoleServerPort'), + ), + migrations.AlterField( + model_name='consoleport', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='consoleport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AlterField( + model_name='consoleserverport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.Device'), + ), + migrations.AddField( + model_name='consoleserverport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='consoleserverport', + name='connection_status', + field=models.NullBooleanField(), + ), + + # Alter power port models + migrations.RenameField( + model_name='powerport', + old_name='power_outlet', + new_name='connected_endpoint' + ), + migrations.AlterField( + model_name='powerport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.Device'), + ), + migrations.AlterField( + model_name='powerport', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_endpoint', to='dcim.PowerOutlet'), + ), + migrations.AlterField( + model_name='powerport', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='powerport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AlterField( + model_name='poweroutlet', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.Device'), + ), + migrations.AddField( + model_name='poweroutlet', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='poweroutlet', + name='connection_status', + field=models.NullBooleanField(), + ), + + # Alter the Interface model + migrations.AddField( + model_name='interface', + name='_connected_circuittermination', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.CircuitTermination'), + ), + migrations.AddField( + model_name='interface', + name='_connected_interface', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'), + ), + migrations.AddField( + model_name='interface', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='interface', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Alter front/rear port models + migrations.AddField( + model_name='frontport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + migrations.AddField( + model_name='rearport', + name='cable', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable'), + ), + + # Copy console/power/interface connections as Cables + migrations.RunPython(console_connections_to_cables), + migrations.RunPython(power_connections_to_cables), + migrations.RunPython(interface_connections_to_cables), + + # Delete the InterfaceConnection model and its ContentType + migrations.RunPython(delete_interfaceconnection_content_type), + migrations.RemoveField( + model_name='interfaceconnection', + name='interface_a', + ), + migrations.RemoveField( + model_name='interfaceconnection', + name='interface_b', + ), + migrations.DeleteModel( + name='InterfaceConnection', + ), + ] diff --git a/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py b/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py new file mode 100644 index 000000000..e78ccd8b6 --- /dev/null +++ b/netbox/dcim/migrations/0067_device_type_remove_qualifiers.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.8 on 2018-10-26 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0066_cables'), + ] + + operations = [ + migrations.RemoveField( + model_name='devicetype', + name='is_console_server', + ), + migrations.RemoveField( + model_name='devicetype', + name='is_network_device', + ), + migrations.RemoveField( + model_name='devicetype', + name='is_pdu', + ), + migrations.RemoveField( + model_name='devicetype', + name='interface_ordering', + ), + ] diff --git a/netbox/dcim/migrations/0068_rack_new_fields.py b/netbox/dcim/migrations/0068_rack_new_fields.py new file mode 100644 index 000000000..5ad4703e4 --- /dev/null +++ b/netbox/dcim/migrations/0068_rack_new_fields.py @@ -0,0 +1,38 @@ +from django.db import migrations, models + +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0067_device_type_remove_qualifiers'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='status', + field=models.PositiveSmallIntegerField(default=3), + ), + migrations.AddField( + model_name='rack', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, unique=True), + ), + migrations.AddField( + model_name='rack', + name='outer_depth', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='outer_unit', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='rack', + name='outer_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 33885e203..5dcf8a492 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,33 +1,47 @@ -from __future__ import unicode_literals - from collections import OrderedDict from itertools import count, groupby from django.conf import settings from django.contrib.auth.models import User -from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField -from circuits.models import Circuit -from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange -from extras.rpc import RPC_CLIENTS from utilities.fields import ColorField, NullableCharField -from utilities.managers import NaturalOrderByManager +from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel -from utilities.utils import serialize_object +from utilities.utils import serialize_object, to_meters from .constants import * from .fields import ASNField, MACAddressField -from .querysets import InterfaceQuerySet +from .managers import DeviceComponentManager, InterfaceManager + + +class ComponentTemplateModel(models.Model): + + class Meta: + abstract = True + + def log_change(self, user, request_id, action): + """ + Log an ObjectChange including the parent DeviceType. + """ + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=self.device_type, + action=action, + object_data=serialize_object(self) + ).save() class ComponentModel(models.Model): @@ -35,30 +49,105 @@ class ComponentModel(models.Model): class Meta: abstract = True - def get_component_parent(self): - raise NotImplementedError( - "ComponentModel must implement get_component_parent()" - ) - def log_change(self, user, request_id, action): """ Log an ObjectChange including the parent Device/VM. """ + parent = self.device if self.device is not None else getattr(self, 'virtual_machine', None) ObjectChange( user=user, request_id=request_id, changed_object=self, - related_object=self.get_component_parent(), + related_object=parent, action=action, object_data=serialize_object(self) ).save() +class CableTermination(models.Model): + cable = models.ForeignKey( + to='dcim.Cable', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + + # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. + _cabled_as_a = GenericRelation( + to='dcim.Cable', + content_type_field='termination_a_type', + object_id_field='termination_a_id' + ) + _cabled_as_b = GenericRelation( + to='dcim.Cable', + content_type_field='termination_b_type', + object_id_field='termination_b_id' + ) + + class Meta: + abstract = True + + def trace(self, position=1, follow_circuits=False): + """ + Return a list representing a complete cable path, with each individual segment represented as a three-tuple: + [ + (termination A, cable, termination B), + (termination C, cable, termination D), + (termination E, cable, termination F) + ] + """ + def get_peer_port(termination, position=1, follow_circuits=False): + from circuits.models import CircuitTermination + + # Map a front port to its corresponding rear port + if isinstance(termination, FrontPort): + return termination.rear_port, termination.rear_port_position + + # Map a rear port/position to its corresponding front port + elif isinstance(termination, RearPort): + if position not in range(1, termination.positions + 1): + raise Exception("Invalid position for {} ({} positions): {})".format( + termination, termination.positions, position + )) + peer_port = FrontPort.objects.get( + rear_port=termination, + rear_port_position=position, + ) + return peer_port, 1 + + # Follow a circuit to its other termination + elif isinstance(termination, CircuitTermination) and follow_circuits: + peer_termination = termination.get_peer_termination() + if peer_termination is None: + return None, None + return peer_termination, position + + # Termination is not a pass-through port + else: + return None, None + + if not self.cable: + return [(self, None, None)] + + far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a + path = [(self, self.cable, far_end)] + + peer_port, position = get_peer_port(far_end, position, follow_circuits) + if peer_port is None: + return path + + next_segment = peer_port.trace(position) + if next_segment is None: + return path + [(peer_port, None, None)] + + return path + next_segment + + # # Regions # -@python_2_unicode_compatible class Region(MPTTModel, ChangeLoggedModel): """ Sites can be grouped within geographic Regions. @@ -102,11 +191,6 @@ class Region(MPTTModel, ChangeLoggedModel): # Sites # -class SiteManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible class Site(ChangeLoggedModel, CustomFieldModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility @@ -197,7 +281,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = SiteManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -256,6 +340,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): @property def count_circuits(self): + from circuits.models import Circuit return Circuit.objects.filter(terminations__site=self).count() @property @@ -268,7 +353,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): # Racks # -@python_2_unicode_compatible class RackGroup(ChangeLoggedModel): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For @@ -308,7 +392,6 @@ class RackGroup(ChangeLoggedModel): ) -@python_2_unicode_compatible class RackRole(ChangeLoggedModel): """ Racks can be organized by functional role, similar to Devices. @@ -341,11 +424,6 @@ class RackRole(ChangeLoggedModel): ) -class RackManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible class Rack(ChangeLoggedModel, CustomFieldModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. @@ -379,6 +457,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) + status = models.PositiveSmallIntegerField( + choices=RACK_STATUS_CHOICES, + default=RACK_STATUS_ACTIVE + ) role = models.ForeignKey( to='dcim.RackRole', on_delete=models.PROTECT, @@ -391,6 +473,14 @@ class Rack(ChangeLoggedModel, CustomFieldModel): blank=True, verbose_name='Serial number' ) + asset_tag = NullableCharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this rack' + ) type = models.PositiveSmallIntegerField( choices=RACK_TYPE_CHOICES, blank=True, @@ -413,6 +503,19 @@ class Rack(ChangeLoggedModel, CustomFieldModel): verbose_name='Descending units', help_text='Units are numbered top-to-bottom' ) + outer_width = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + outer_depth = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + outer_unit = models.PositiveSmallIntegerField( + choices=RACK_DIMENSION_UNIT_CHOICES, + blank=True, + null=True + ) comments = models.TextField( blank=True ) @@ -425,12 +528,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = RackManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', - 'desc_units', 'comments', + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', + 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] class Meta: @@ -441,13 +544,19 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ] def __str__(self): - return self.display_name or super(Rack, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) def clean(self): + # Validate outer dimensions and unit + if (self.outer_width is not None or self.outer_depth is not None) and self.outer_unit is None: + raise ValidationError("Must specify a unit when setting an outer width/depth") + elif self.outer_width is None and self.outer_depth is None: + self.outer_unit = None + if self.pk: # Validate that Rack is tall enough to house the installed Devices top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first() @@ -473,7 +582,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): if self.pk: _site_id = Rack.objects.get(pk=self.pk).site_id - super(Rack, self).save(*args, **kwargs) + super().save(*args, **kwargs) # Update racked devices if the assigned Site has been changed. if _site_id is not None and self.site_id != _site_id: @@ -486,12 +595,17 @@ class Rack(ChangeLoggedModel, CustomFieldModel): self.name, self.facility_id, self.tenant.name if self.tenant else None, + self.get_status_display(), self.role.name if self.role else None, self.get_type_display() if self.type else None, self.serial, + self.asset_tag, self.width, self.u_height, self.desc_units, + self.outer_width, + self.outer_depth, + self.outer_unit, self.comments, ) @@ -510,6 +624,9 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return self.name return "" + def get_status_class(self): + return STATUS_CLASSES[self.status] + def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): """ Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} @@ -603,7 +720,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel): return int(float(self.u_height - u_available) / self.u_height * 100) -@python_2_unicode_compatible class RackReservation(ChangeLoggedModel): """ One or more reserved units within a Rack. @@ -677,7 +793,6 @@ class RackReservation(ChangeLoggedModel): # Device Types # -@python_2_unicode_compatible class Manufacturer(ChangeLoggedModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -708,7 +823,6 @@ class Manufacturer(ChangeLoggedModel): ) -@python_2_unicode_compatible class DeviceType(ChangeLoggedModel, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as @@ -747,25 +861,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): verbose_name='Is full depth', help_text='Device consumes both front and rear rack faces' ) - interface_ordering = models.PositiveSmallIntegerField( - choices=IFACE_ORDERING_CHOICES, - default=IFACE_ORDERING_POSITION - ) - is_console_server = models.BooleanField( - default=False, - verbose_name='Is a console server', - help_text='This type of device has console server ports' - ) - is_pdu = models.BooleanField( - default=False, - verbose_name='Is a PDU', - help_text='This type of device has power outlets' - ) - is_network_device = models.BooleanField( - default=True, - verbose_name='Is a network device', - help_text='This type of device has network interfaces' - ) subdevice_role = models.NullBooleanField( default=None, verbose_name='Parent/child status', @@ -785,8 +880,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager() csv_headers = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', ] class Meta: @@ -800,7 +894,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): return self.model def __init__(self, *args, **kwargs): - super(DeviceType, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Save a copy of u_height for validation in clean() self._original_u_height = self.u_height @@ -816,11 +910,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): self.part_number, self.u_height, self.is_full_depth, - self.is_console_server, - self.is_pdu, - self.is_network_device, self.get_subdevice_role_display() if self.subdevice_role else None, - self.get_interface_ordering_display(), self.comments, ) @@ -840,24 +930,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): "{}U".format(d, d.rack, self.u_height) }) - if not self.is_console_server and self.cs_port_templates.count(): - raise ValidationError({ - 'is_console_server': "Must delete all console server port templates associated with this device before " - "declassifying it as a console server." - }) - - if not self.is_pdu and self.power_outlet_templates.count(): - raise ValidationError({ - 'is_pdu': "Must delete all power outlet templates associated with this device before declassifying it " - "as a PDU." - }) - - if not self.is_network_device and self.interface_templates.filter(mgmt_only=False).count(): - raise ValidationError({ - 'is_network_device': "Must delete all non-management-only interface templates associated with this " - "device before declassifying it as a network device." - }) - if self.subdevice_role != SUBDEVICE_ROLE_PARENT and self.device_bay_templates.count(): raise ValidationError({ 'subdevice_role': "Must delete all device bay templates associated with this device before " @@ -882,20 +954,21 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): return bool(self.subdevice_role is False) -@python_2_unicode_compatible -class ConsolePortTemplate(ComponentModel): +class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='console_port_templates' + related_name='consoleport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -903,24 +976,22 @@ class ConsolePortTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class ConsoleServerPortTemplate(ComponentModel): +class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='cs_port_templates' + related_name='consoleserverport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -928,24 +999,22 @@ class ConsoleServerPortTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class PowerPortTemplate(ComponentModel): +class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='power_port_templates' + related_name='powerport_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -953,24 +1022,22 @@ class PowerPortTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class PowerOutletTemplate(ComponentModel): +class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. """ device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, - related_name='power_outlet_templates' + related_name='poweroutlet_templates' ) name = models.CharField( max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -978,12 +1045,8 @@ class PowerOutletTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - -@python_2_unicode_compatible -class InterfaceTemplate(ComponentModel): +class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. """ @@ -1004,7 +1067,7 @@ class InterfaceTemplate(ComponentModel): verbose_name='Management only' ) - objects = InterfaceQuerySet.as_manager() + objects = InterfaceManager() class Meta: ordering = ['device_type', 'name'] @@ -1013,12 +1076,92 @@ class InterfaceTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type + +class FrontPortTemplate(ComponentTemplateModel): + """ + Template for a pass-through port on the front of a new Device. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='frontport_templates' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + rear_port = models.ForeignKey( + to='dcim.RearPortTemplate', + on_delete=models.CASCADE, + related_name='frontport_templates' + ) + rear_port_position = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + + objects = DeviceComponentManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = [ + ['device_type', 'name'], + ['rear_port', 'rear_port_position'], + ] + + def __str__(self): + return self.name + + def clean(self): + + # Validate rear port assignment + if self.rear_port.device_type != self.device_type: + raise ValidationError( + "Rear port ({}) must belong to the same device type".format(self.rear_port) + ) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) -@python_2_unicode_compatible -class DeviceBayTemplate(ComponentModel): +class RearPortTemplate(ComponentTemplateModel): + """ + Template for a pass-through port on the rear of a new Device. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='rearport_templates' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + positions = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + + objects = DeviceComponentManager() + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __str__(self): + return self.name + + +class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ @@ -1031,6 +1174,8 @@ class DeviceBayTemplate(ComponentModel): max_length=50 ) + objects = DeviceComponentManager() + class Meta: ordering = ['device_type', 'name'] unique_together = ['device_type', 'name'] @@ -1038,15 +1183,11 @@ class DeviceBayTemplate(ComponentModel): def __str__(self): return self.name - def get_component_parent(self): - return self.device_type - # # Devices # -@python_2_unicode_compatible class DeviceRole(ChangeLoggedModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -1084,7 +1225,6 @@ class DeviceRole(ChangeLoggedModel): ) -@python_2_unicode_compatible class Platform(ChangeLoggedModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -1118,12 +1258,6 @@ class Platform(ChangeLoggedModel): verbose_name='NAPALM arguments', help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)' ) - rpc_client = models.CharField( - max_length=30, - choices=RPC_CLIENT_CHOICES, - blank=True, - verbose_name='Legacy RPC client' - ) csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] @@ -1146,11 +1280,6 @@ class Platform(ChangeLoggedModel): ) -class DeviceManager(NaturalOrderByManager): - natural_order_field = 'name' - - -@python_2_unicode_compatible class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, @@ -1288,7 +1417,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): to='extras.ImageAttachment' ) - objects = DeviceManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = [ @@ -1308,7 +1437,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) def __str__(self): - return self.display_name or super(Device, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) @@ -1423,30 +1552,47 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): is_new = not bool(self.pk) - super(Device, self).save(*args, **kwargs) + super().save(*args, **kwargs) # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( [ConsolePort(device=self, name=template.name) for template in - self.device_type.console_port_templates.all()] + self.device_type.consoleport_templates.all()] ) ConsoleServerPort.objects.bulk_create( [ConsoleServerPort(device=self, name=template.name) for template in - self.device_type.cs_port_templates.all()] + self.device_type.consoleserverport_templates.all()] ) PowerPort.objects.bulk_create( [PowerPort(device=self, name=template.name) for template in - self.device_type.power_port_templates.all()] + self.device_type.powerport_templates.all()] ) PowerOutlet.objects.bulk_create( [PowerOutlet(device=self, name=template.name) for template in - self.device_type.power_outlet_templates.all()] + self.device_type.poweroutlet_templates.all()] ) Interface.objects.bulk_create( [Interface(device=self, name=template.name, form_factor=template.form_factor, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] ) + RearPort.objects.bulk_create([ + RearPort( + device=self, + name=template.name, + type=template.type, + positions=template.positions + ) for template in self.device_type.rearport_templates.all() + ]) + FrontPort.objects.bulk_create([ + FrontPort( + device=self, + name=template.name, + type=template.type, + rear_port=RearPort.objects.get(device=self, name=template.rear_port.name), + rear_port_position=template.rear_port_position, + ) for template in self.device_type.frontport_templates.all() + ]) DeviceBay.objects.bulk_create( [DeviceBay(device=self, name=template.name) for template in self.device_type.device_bay_templates.all()] @@ -1530,48 +1676,39 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): def get_status_class(self): return STATUS_CLASSES[self.status] - def get_rpc_client(self): - """ - Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined. - """ - if not self.platform: - return None - return RPC_CLIENTS.get(self.platform.rpc_client) - # # Console ports # -@python_2_unicode_compatible -class ConsolePort(ComponentModel): +class ConsolePort(CableTermination, ComponentModel): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='console_ports' + related_name='consoleports' ) name = models.CharField( max_length=50 ) - cs_port = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.ConsoleServerPort', on_delete=models.SET_NULL, - related_name='connected_console', - verbose_name='Console server port', + related_name='connected_endpoint', blank=True, null=True ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED + blank=True ) + objects = DeviceComponentManager() tags = TaggableManager() - csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1583,16 +1720,10 @@ class ConsolePort(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device - def to_csv(self): return ( - self.cs_port.device.identifier if self.cs_port else None, - self.cs_port.name if self.cs_port else None, self.device.identifier, self.name, - self.get_connection_status_display(), ) @@ -1600,33 +1731,28 @@ class ConsolePort(ComponentModel): # Console server ports # -class ConsoleServerPortManager(models.Manager): - - def get_queryset(self): - # Pad any trailing digits to effect natural sorting - return super(ConsoleServerPortManager, self).get_queryset().extra(select={ - 'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), " - r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))", - }).order_by('device', 'name_padded') - - -@python_2_unicode_compatible -class ConsoleServerPort(ComponentModel): +class ConsoleServerPort(CableTermination, ComponentModel): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='cs_ports' + related_name='consoleserverports' ) name = models.CharField( max_length=50 ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) - objects = ConsoleServerPortManager() + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1636,53 +1762,45 @@ class ConsoleServerPort(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device - - def clean(self): - - # Check that the parent device's DeviceType is a console server - if self.device is None: - raise ValidationError("Console server ports must be assigned to devices.") - device_type = self.device.device_type - if not device_type.is_console_server: - raise ValidationError("The {} {} device type does not support assignment of console server ports.".format( - device_type.manufacturer, device_type - )) + def to_csv(self): + return ( + self.device.identifier, + self.name, + ) # # Power ports # -@python_2_unicode_compatible -class PowerPort(ComponentModel): +class PowerPort(CableTermination, ComponentModel): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='power_ports' + related_name='powerports' ) name = models.CharField( max_length=50 ) - power_outlet = models.OneToOneField( + connected_endpoint = models.OneToOneField( to='dcim.PowerOutlet', on_delete=models.SET_NULL, - related_name='connected_port', + related_name='connected_endpoint', blank=True, null=True ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED + blank=True ) + objects = DeviceComponentManager() tags = TaggableManager() - csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] + csv_headers = ['device', 'name'] class Meta: ordering = ['device', 'name'] @@ -1694,16 +1812,10 @@ class PowerPort(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device - def to_csv(self): return ( - self.power_outlet.device.identifier if self.power_outlet else None, - self.power_outlet.name if self.power_outlet else None, self.device.identifier, self.name, - self.get_connection_status_display(), ) @@ -1711,33 +1823,28 @@ class PowerPort(ComponentModel): # Power outlets # -class PowerOutletManager(models.Manager): - - def get_queryset(self): - # Pad any trailing digits to effect natural sorting - return super(PowerOutletManager, self).get_queryset().extra(select={ - 'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), " - r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))", - }).order_by('device', 'name_padded') - - -@python_2_unicode_compatible -class PowerOutlet(ComponentModel): +class PowerOutlet(CableTermination, ComponentModel): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ device = models.ForeignKey( to='dcim.Device', on_delete=models.CASCADE, - related_name='power_outlets' + related_name='poweroutlets' ) name = models.CharField( max_length=50 ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) - objects = PowerOutletManager() + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name'] + class Meta: unique_together = ['device', 'name'] @@ -1747,30 +1854,21 @@ class PowerOutlet(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device - - def clean(self): - - # Check that the parent device's DeviceType is a PDU - if self.device is None: - raise ValidationError("Power outlets must be assigned to devices.") - device_type = self.device.device_type - if not device_type.is_pdu: - raise ValidationError("The {} {} device type does not support assignment of power outlets.".format( - device_type.manufacturer, device_type - )) + def to_csv(self): + return ( + self.device.identifier, + self.name, + ) # # Interfaces # -@python_2_unicode_compatible -class Interface(ComponentModel): +class Interface(CableTermination, ComponentModel): """ A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other - Interface via the creation of an InterfaceConnection. + Interface. """ device = models.ForeignKey( to='Device', @@ -1786,6 +1884,27 @@ class Interface(ComponentModel): null=True, blank=True ) + name = models.CharField( + max_length=64 + ) + _connected_interface = models.OneToOneField( + to='self', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + _connected_circuittermination = models.OneToOneField( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -1794,9 +1913,6 @@ class Interface(ComponentModel): blank=True, verbose_name='Parent LAG' ) - name = models.CharField( - max_length=64 - ) form_factor = models.PositiveSmallIntegerField( choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS @@ -1844,9 +1960,14 @@ class Interface(ComponentModel): verbose_name='Tagged VLANs' ) - objects = InterfaceQuerySet.as_manager() + objects = InterfaceManager() tags = TaggableManager() + csv_headers = [ + 'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only', + 'description', 'mode', + ] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -1857,19 +1978,23 @@ class Interface(ComponentModel): def get_absolute_url(self): return reverse('dcim:interface', kwargs={'pk': self.pk}) - def get_component_parent(self): - return self.device or self.virtual_machine + def to_csv(self): + return ( + self.device.identifier if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.lag.name if self.lag else None, + self.get_form_factor_display(), + self.enabled, + self.mac_address, + self.mtu, + self.mgmt_only, + self.description, + self.get_mode_display(), + ) def clean(self): - # Check that the parent device's DeviceType is a network device - if self.device is not None: - device_type = self.device.device_type - if not device_type.is_network_device: - raise ValidationError("The {} {} device type does not support assignment of network interfaces.".format( - device_type.manufacturer, device_type - )) - # An Interface must belong to a Device *or* to a VirtualMachine if self.device and self.virtual_machine: raise ValidationError("An interface cannot belong to both a device and a virtual machine.") @@ -1883,7 +2008,9 @@ class Interface(ComponentModel): }) # Virtual interfaces cannot be connected - if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and ( + self.cable or getattr(self, 'circuit_termination', False) + ): raise ValidationError({ 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " "Disconnect the interface or choose a suitable form factor." @@ -1928,7 +2055,7 @@ class Interface(ComponentModel): if self.pk and self.mode is not IFACE_MODE_TAGGED: self.tagged_vlans.clear() - return super(Interface, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def log_change(self, user, request_id, action): """ @@ -1939,7 +2066,7 @@ class Interface(ComponentModel): # the component parent will raise DoesNotExist. For more discussion, see # https://github.com/digitalocean/netbox/issues/2323 try: - parent_obj = self.get_component_parent() + parent_obj = self.device or self.virtual_machine except ObjectDoesNotExist: parent_obj = None @@ -1949,13 +2076,33 @@ class Interface(ComponentModel): changed_object=self, related_object=parent_obj, action=action, - object_data=serialize_object(self, extra={ - 'connected_interface': self.connected_interface.pk if self.connection else None, - 'connection_status': self.connection.connection_status if self.connection else None, - }) + object_data=serialize_object(self) ).save() - # TODO: Replace `parent` with get_component_parent() (from ComponentModel) + @property + def connected_endpoint(self): + if self._connected_interface: + return self._connected_interface + return self._connected_circuittermination + + @connected_endpoint.setter + def connected_endpoint(self, value): + from circuits.models import CircuitTermination + + if value is None: + self._connected_interface = None + self._connected_circuittermination = None + elif isinstance(value, Interface): + self._connected_interface = value + self._connected_circuittermination = None + elif isinstance(value, CircuitTermination): + self._connected_interface = None + self._connected_circuittermination = value + else: + raise ValueError( + "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value)) + ) + @property def parent(self): return self.device or self.virtual_machine @@ -1977,152 +2124,135 @@ class Interface(ComponentModel): return self.form_factor == IFACE_FF_LAG @property - def is_connected(self): - try: - return bool(self.circuit_termination) - except ObjectDoesNotExist: - pass - return bool(self.connection) - - @property - def connection(self): - try: - return self.connected_as_a - except ObjectDoesNotExist: - pass - try: - return self.connected_as_b - except ObjectDoesNotExist: - pass - return None - - @property - def connected_interface(self): - try: - if self.connected_as_a: - return self.connected_as_a.interface_b - except ObjectDoesNotExist: - pass - try: - if self.connected_as_b: - return self.connected_as_b.interface_a - except ObjectDoesNotExist: - pass - return None + def count_ipaddresses(self): + return self.ip_addresses.count() -class InterfaceConnection(models.Model): +# +# Pass-through ports +# + +class FrontPort(CableTermination, ComponentModel): """ - An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no - significant difference between the interface_a and interface_b fields. + A pass-through port on the front of a Device. """ - interface_a = models.OneToOneField( - to='dcim.Interface', + device = models.ForeignKey( + to='dcim.Device', on_delete=models.CASCADE, - related_name='connected_as_a' + related_name='frontports' ) - interface_b = models.OneToOneField( - to='dcim.Interface', + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + rear_port = models.ForeignKey( + to='dcim.RearPort', on_delete=models.CASCADE, - related_name='connected_as_b' + related_name='frontports' ) - connection_status = models.BooleanField( - choices=CONNECTION_STATUS_CHOICES, - default=CONNECTION_STATUS_CONNECTED, - verbose_name='Status' + rear_port_position = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + description = models.CharField( + max_length=100, + blank=True ) - csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] + objects = DeviceComponentManager() + tags = TaggableManager() - def clean(self): + csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] - # An interface cannot be connected to itself - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) + class Meta: + ordering = ['device', 'name'] + unique_together = [ + ['device', 'name'], + ['rear_port', 'rear_port_position'], + ] - # Only connectable interface types are permitted - if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_a': '{} is not a connectable interface type.'.format( - self.interface_a.get_form_factor_display() - ) - }) - if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_b': '{} is not a connectable interface type.'.format( - self.interface_b.get_form_factor_display() - ) - }) - - # Prevent the A side of one connection from being the B side of another - interface_a_connections = InterfaceConnection.objects.filter( - Q(interface_a=self.interface_a) | - Q(interface_b=self.interface_a) - ).exclude(pk=self.pk) - if interface_a_connections.exists(): - raise ValidationError({ - 'interface_a': "This interface is already connected." - }) - interface_b_connections = InterfaceConnection.objects.filter( - Q(interface_a=self.interface_b) | - Q(interface_b=self.interface_b) - ).exclude(pk=self.pk) - if interface_b_connections.exists(): - raise ValidationError({ - 'interface_b': "This interface is already connected." - }) + def __str__(self): + return self.name def to_csv(self): return ( - self.interface_a.device.identifier, - self.interface_a.name, - self.interface_b.device.identifier, - self.interface_b.name, - self.get_connection_status_display(), + self.device.identifier, + self.name, + self.get_type_display(), + self.rear_port.name, + self.rear_port_position, + self.description, ) - def log_change(self, user, request_id, action): - """ - Create a new ObjectChange for each of the two affected Interfaces. - """ - interfaces = ( - (self.interface_a, self.interface_b), - (self.interface_b, self.interface_a), + def clean(self): + + # Validate rear port assignment + if self.rear_port.device != self.device: + raise ValidationError( + "Rear port ({}) must belong to the same device".format(self.rear_port) + ) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) + + +class RearPort(CableTermination, ComponentModel): + """ + A pass-through port on the rear of a Device. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='rearports' + ) + name = models.CharField( + max_length=64 + ) + type = models.PositiveSmallIntegerField( + choices=PORT_TYPE_CHOICES + ) + positions = models.PositiveSmallIntegerField( + default=1, + validators=[MinValueValidator(1), MaxValueValidator(64)] + ) + description = models.CharField( + max_length=100, + blank=True + ) + + objects = DeviceComponentManager() + tags = TaggableManager() + + csv_headers = ['device', 'name', 'type', 'positions', 'description'] + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __str__(self): + return self.name + + def to_csv(self): + return ( + self.device.identifier, + self.name, + self.get_type_display(), + self.positions, + self.description, ) - for interface, peer_interface in interfaces: - if action == OBJECTCHANGE_ACTION_DELETE: - connection_data = { - 'connected_interface': None, - } - else: - connection_data = { - 'connected_interface': peer_interface.pk, - 'connection_status': self.connection_status - } - - try: - parent_obj = interface.parent - except ObjectDoesNotExist: - parent_obj = None - - ObjectChange( - user=user, - request_id=request_id, - changed_object=interface, - related_object=parent_obj, - action=OBJECTCHANGE_ACTION_UPDATE, - object_data=serialize_object(interface, extra=connection_data) - ).save() - # # Device bays # -@python_2_unicode_compatible class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -2144,8 +2274,11 @@ class DeviceBay(ComponentModel): null=True ) + objects = DeviceComponentManager() tags = TaggableManager() + csv_headers = ['device', 'name', 'installed_device'] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -2156,8 +2289,12 @@ class DeviceBay(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device + def to_csv(self): + return ( + self.device.identifier, + self.name, + self.installed_device.identifier if self.installed_device else None, + ) def clean(self): @@ -2176,7 +2313,6 @@ class DeviceBay(ComponentModel): # Inventory items # -@python_2_unicode_compatible class InventoryItem(ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -2248,9 +2384,6 @@ class InventoryItem(ComponentModel): def get_absolute_url(self): return self.device.get_absolute_url() - def get_component_parent(self): - return self.device - def to_csv(self): return ( self.device.name or '{' + self.device.pk + '}', @@ -2268,7 +2401,6 @@ class InventoryItem(ComponentModel): # Virtual chassis # -@python_2_unicode_compatible class VirtualChassis(ChangeLoggedModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). @@ -2311,3 +2443,191 @@ class VirtualChassis(ChangeLoggedModel): self.master, self.domain, ) + + +# +# Cables +# + +class Cable(ChangeLoggedModel): + """ + A physical connection between two endpoints. + """ + termination_a_type = models.ForeignKey( + to=ContentType, + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + termination_a_id = models.PositiveIntegerField() + termination_a = GenericForeignKey( + ct_field='termination_a_type', + fk_field='termination_a_id' + ) + termination_b_type = models.ForeignKey( + to=ContentType, + limit_choices_to={'model__in': CABLE_TERMINATION_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + termination_b_id = models.PositiveIntegerField() + termination_b = GenericForeignKey( + ct_field='termination_b_type', + fk_field='termination_b_id' + ) + type = models.PositiveSmallIntegerField( + choices=CABLE_TYPE_CHOICES, + blank=True, + null=True + ) + status = models.BooleanField( + choices=CONNECTION_STATUS_CHOICES, + default=CONNECTION_STATUS_CONNECTED + ) + label = models.CharField( + max_length=100, + blank=True + ) + color = ColorField( + blank=True + ) + length = models.PositiveSmallIntegerField( + blank=True, + null=True + ) + length_unit = models.PositiveSmallIntegerField( + choices=CABLE_LENGTH_UNIT_CHOICES, + blank=True, + null=True + ) + # Stores the normalized length (in meters) for database ordering + _abs_length = models.DecimalField( + max_digits=10, + decimal_places=4, + blank=True, + null=True + ) + + csv_headers = [ + 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] + + class Meta: + ordering = ['pk'] + unique_together = ( + ('termination_a_type', 'termination_a_id'), + ('termination_b_type', 'termination_b_id'), + ) + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Create an ID string for use by __str__(). We have to save a copy of pk since it's nullified after .delete() + # is called. + self.id_string = '#{}'.format(self.pk) + + def __str__(self): + return self.label or self.id_string + + def get_absolute_url(self): + return reverse('dcim:cable', args=[self.pk]) + + def clean(self): + + # Check that termination types are compatible + type_a = self.termination_a_type.model + type_b = self.termination_b_type.model + if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): + raise ValidationError("Incompatible termination types: {} and {}".format( + self.termination_a_type, self.termination_b_type + )) + + # A termination point cannot be connected to itself + if self.termination_a == self.termination_b: + raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) + + # A front port cannot be connected to its corresponding rear port + if ( + type_a in ['frontport', 'rearport'] and + type_b in ['frontport', 'rearport'] and + ( + getattr(self.termination_a, 'rear_port', None) == self.termination_b or + getattr(self.termination_b, 'rear_port', None) == self.termination_a + ) + ): + raise ValidationError("A front port cannot be connected to it corresponding rear port") + + # Check for an existing Cable connected to either termination object + if self.termination_a.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_a, self.termination_a.cable_id + )) + if self.termination_b.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_b, self.termination_b.cable_id + )) + + # Virtual interfaces cannot be connected + endpoint_a, endpoint_b, _ = self.get_path_endpoints() + if ( + ( + isinstance(endpoint_a, Interface) and + endpoint_a.form_factor == IFACE_FF_VIRTUAL + ) or + ( + isinstance(endpoint_b, Interface) and + endpoint_b.form_factor == IFACE_FF_VIRTUAL + ) + ): + raise ValidationError("Cannot connect to a virtual interface") + + # Validate length and length_unit + if self.length is not None and self.length_unit is None: + raise ValidationError("Must specify a unit when setting a cable length") + elif self.length is None: + self.length_unit = None + + def save(self, *args, **kwargs): + + # Store the given length (if any) in meters for use in database ordering + if self.length and self.length_unit: + self._abs_length = to_meters(self.length, self.length_unit) + + super().save(*args, **kwargs) + + def to_csv(self): + return ( + '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model), + self.termination_a_id, + '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model), + self.termination_b_id, + self.get_type_display(), + self.get_status_display(), + self.label, + self.color, + self.length, + self.length_unit, + ) + + def get_path_endpoints(self): + """ + Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be + None. + """ + a_path = self.termination_b.trace() + b_path = self.termination_a.trace() + + # Determine overall path status (connected or planned) + if self.status == CONNECTION_STATUS_PLANNED: + path_status = CONNECTION_STATUS_PLANNED + else: + path_status = CONNECTION_STATUS_CONNECTED + for segment in a_path[1:] + b_path[1:]: + if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED: + path_status = CONNECTION_STATUS_PLANNED + break + + # (A path end, B path end, connected/planned) + return a_path[-1][2], b_path[-1][2], path_status diff --git a/netbox/dcim/querysets.py b/netbox/dcim/querysets.py deleted file mode 100644 index 32275ce01..000000000 --- a/netbox/dcim/querysets.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import unicode_literals - -from django.db.models import QuerySet -from django.db.models.expressions import RawSQL - -from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES - - -class InterfaceQuerySet(QuerySet): - - def order_naturally(self, method=IFACE_ORDERING_POSITION): - """ - Naturally order interfaces by their type and numeric position. The sort method must be one of the defined - IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - - To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), - slot, subslot, position, channel, and virtual circuit: - - {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3 - would be parsed as follows: - - name = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = 0 - channel = None - vc = 0 - - The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of - the prescribed fields. - """ - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = { - IFACE_ORDERING_POSITION: ( - '_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name', - ), - IFACE_ORDERING_NAME: ( - '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name', - ), - }[method] - - TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')" - ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(\d{{1,9}})$') AS integer)" - SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})\/') AS integer)" - SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/)(\d{{1,9}})') AS integer), 0)" - POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{2}}(\d{{1,9}})') AS integer), 0)" - SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}\/){{3}}(\d{{1,9}})') AS integer), 0)" - CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" - VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.(\d{{1,9}})$') AS integer), 0)" - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return self.annotate(**fields).order_by(*ordering) - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 80e47391a..2ac3bee06 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals - from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver -from .models import Device, VirtualChassis +from .models import Cable, Device, VirtualChassis @receiver(post_save, sender=VirtualChassis) @@ -21,3 +19,53 @@ def clear_virtualchassis_members(instance, **kwargs): When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members. """ Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None) + + +@receiver(post_save, sender=Cable) +def update_connected_endpoints(instance, **kwargs): + """ + When a Cable is saved, check for and update its two connected endpoints + """ + + # Cache the Cable on its two termination points + if instance.termination_a.cable != instance: + instance.termination_a.cable = instance + instance.termination_a.save() + if instance.termination_b.cable != instance: + instance.termination_b.cable = instance + instance.termination_b.save() + + # Check if this Cable has formed a complete path. If so, update both endpoints. + endpoint_a, endpoint_b, path_status = instance.get_path_endpoints() + if endpoint_a is not None and endpoint_b is not None: + endpoint_a.connected_endpoint = endpoint_b + endpoint_a.connection_status = path_status + endpoint_a.save() + endpoint_b.connected_endpoint = endpoint_a + endpoint_b.connection_status = path_status + endpoint_b.save() + + +@receiver(pre_delete, sender=Cable) +def nullify_connected_endpoints(instance, **kwargs): + """ + When a Cable is deleted, check for and update its two connected endpoints + """ + endpoint_a, endpoint_b, _ = instance.get_path_endpoints() + + # Disassociate the Cable from its termination points + if instance.termination_a is not None: + instance.termination_a.cable = None + instance.termination_a.save() + if instance.termination_b is not None: + instance.termination_b.cable = None + instance.termination_b.save() + + # If this Cable was part of a complete path, tear it down + if endpoint_a is not None and endpoint_b is not None: + endpoint_a.connected_endpoint = None + endpoint_a.connection_status = None + endpoint_a.save() + endpoint_b.connected_endpoint = None + endpoint_b.connection_status = None + endpoint_b.save() diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index edd30d89f..b38a60827 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1,15 +1,13 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, BooleanColumn, ToggleColumn +from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem, - Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) REGION_LINK = """ @@ -172,6 +170,18 @@ VIRTUALCHASSIS_ACTIONS = """ {% endif %} """ +CABLE_TERMINATION_PARENT = """ +{% if value.device %} + {{ value.device }} +{% else %} + {{ value.circuit }} +{% endif %} +""" + +CABLE_LENGTH = """ +{% if record.length %}{{ record.length }}{{ record.length_unit }}{% else %}—{% endif %} +""" + # # Regions @@ -264,12 +274,13 @@ class RackTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) + status = tables.TemplateColumn(STATUS_LABEL) role = tables.TemplateColumn(RACK_ROLE) u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') class Meta(BaseTable.Meta): model = Rack - fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') + fields = ('pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height') class RackDetailTable(RackTable): @@ -281,24 +292,11 @@ class RackDetailTable(RackTable): class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'get_utilization', ) -class RackImportTable(BaseTable): - name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - facility_id = tables.Column(verbose_name='Facility ID') - tenant = tables.TemplateColumn(template_code=COL_TENANT) - u_height = tables.Column(verbose_name='Height (U)') - - class Meta(BaseTable.Meta): - model = Rack - fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height') - - # # Rack reservations # @@ -347,9 +345,6 @@ class DeviceTypeTable(BaseTable): verbose_name='Device Type' ) is_full_depth = BooleanColumn(verbose_name='Full Depth') - is_console_server = BooleanColumn(verbose_name='CS') - is_pdu = BooleanColumn(verbose_name='PDU') - is_network_device = BooleanColumn(verbose_name='Net') subdevice_role = tables.TemplateColumn( template_code=SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role' @@ -362,8 +357,8 @@ class DeviceTypeTable(BaseTable): class Meta(BaseTable.Meta): model = DeviceType fields = ( - 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', 'instance_count', + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', + 'instance_count', ) @@ -417,6 +412,24 @@ class InterfaceTemplateTable(BaseTable): empty_text = "None" +class FrontPortTemplateTable(BaseTable): + pk = ToggleColumn() + + class Meta(BaseTable.Meta): + model = FrontPortTemplate + fields = ('pk', 'name', 'type', 'rear_port', 'rear_port_position') + empty_text = "None" + + +class RearPortTemplateTable(BaseTable): + pk = ToggleColumn() + + class Meta(BaseTable.Meta): + model = RearPortTemplate + fields = ('pk', 'name', 'type', 'positions') + empty_text = "None" + + class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() @@ -576,6 +589,22 @@ class InterfaceTable(BaseTable): fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') +class FrontPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = FrontPort + fields = ('name', 'type', 'rear_port', 'rear_port_position', 'description') + empty_text = "None" + + +class RearPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = RearPort + fields = ('name', 'type', 'positions', 'description') + empty_text = "None" + + class DeviceBayTable(BaseTable): class Meta(BaseTable.Meta): @@ -583,47 +612,142 @@ class DeviceBayTable(BaseTable): fields = ('name',) +# +# Cables +# + +class CableTable(BaseTable): + pk = ToggleColumn() + id = tables.LinkColumn( + viewname='dcim:cable', + args=[Accessor('pk')], + verbose_name='ID' + ) + termination_a_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='Termination A' + ) + termination_a = tables.Column( + accessor=Accessor('termination_a'), + orderable=False, + verbose_name='' + ) + termination_b_parent = tables.TemplateColumn( + template_code=CABLE_TERMINATION_PARENT, + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='Termination B' + ) + termination_b = tables.Column( + accessor=Accessor('termination_b'), + orderable=False, + verbose_name='' + ) + length = tables.TemplateColumn( + template_code=CABLE_LENGTH, + order_by='_abs_length' + ) + color = ColorColumn() + + class Meta(BaseTable.Meta): + model = Cable + fields = ( + 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', + 'status', 'type', 'color', 'length', + ) + + # # Device connections # class ConsoleConnectionTable(BaseTable): - console_server = tables.LinkColumn('dcim:device', accessor=Accessor('cs_port.device'), - args=[Accessor('cs_port.device.pk')], verbose_name='Console server') - cs_port = tables.Column(verbose_name='Port') - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - name = tables.Column(verbose_name='Console port') + console_server = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='Console Server' + ) + connected_endpoint = tables.Column( + verbose_name='Port' + ) + device = tables.LinkColumn( + viewname='dcim:device', + args=[Accessor('device.pk')] + ) + name = tables.Column( + verbose_name='Console Port' + ) class Meta(BaseTable.Meta): model = ConsolePort - fields = ('console_server', 'cs_port', 'device', 'name') + fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status') class PowerConnectionTable(BaseTable): - pdu = tables.LinkColumn('dcim:device', accessor=Accessor('power_outlet.device'), - args=[Accessor('power_outlet.device.pk')], verbose_name='PDU') - power_outlet = tables.Column(verbose_name='Outlet') - device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') - name = tables.Column(verbose_name='Power Port') + pdu = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='PDU' + ) + connected_endpoint = tables.Column( + verbose_name='Outlet' + ) + device = tables.LinkColumn( + viewname='dcim:device', + args=[Accessor('device.pk')] + ) + name = tables.Column( + verbose_name='Power Port' + ) class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'power_outlet', 'device', 'name') + fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status') class InterfaceConnectionTable(BaseTable): - device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), - args=[Accessor('interface_a.device.pk')], verbose_name='Device A') - interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'), - args=[Accessor('interface_a.pk')], verbose_name='Interface A') - device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), - args=[Accessor('interface_b.device.pk')], verbose_name='Device B') - interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'), - args=[Accessor('interface_b.pk')], verbose_name='Interface B') + device_a = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('device'), + args=[Accessor('device.pk')], + verbose_name='Device A' + ) + interface_a = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('name'), + args=[Accessor('pk')], + verbose_name='Interface A' + ) + description_a = tables.Column( + accessor=Accessor('description'), + verbose_name='Description' + ) + device_b = tables.LinkColumn( + viewname='dcim:device', + accessor=Accessor('connected_endpoint.device'), + args=[Accessor('connected_endpoint.device.pk')], + verbose_name='Device B' + ) + interface_b = tables.LinkColumn( + viewname='dcim:interface', + accessor=Accessor('connected_endpoint.name'), + args=[Accessor('connected_endpoint.pk')], + verbose_name='Interface B' + ) + description_b = tables.Column( + accessor=Accessor('connected_endpoint.description'), + verbose_name='Description' + ) class Meta(BaseTable.Meta): - model = InterfaceConnection - fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + model = Interface + fields = ( + 'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status', + ) # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 4c60e79d7..980c57e86 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,18 +1,14 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.constants import ( - IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SITE_STATUS_ACTIVE, SUBDEVICE_ROLE_CHILD, - SUBDEVICE_ROLE_PARENT, -) +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from dcim.constants import * from dcim.models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) from ipam.models import IPAddress, VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -24,7 +20,7 @@ class RegionTest(APITestCase): def setUp(self): - super(RegionTest, self).setUp() + super().setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -125,7 +121,7 @@ class SiteTest(APITestCase): def setUp(self): - super(SiteTest, self).setUp() + super().setUp() self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') @@ -260,7 +256,7 @@ class RackGroupTest(APITestCase): def setUp(self): - super(RackGroupTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -370,7 +366,7 @@ class RackRoleTest(APITestCase): def setUp(self): - super(RackRoleTest, self).setUp() + super().setUp() self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') @@ -478,7 +474,7 @@ class RackTest(APITestCase): def setUp(self): - super(RackTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -612,7 +608,7 @@ class RackReservationTest(APITestCase): def setUp(self): - super(RackReservationTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') @@ -723,7 +719,7 @@ class ManufacturerTest(APITestCase): def setUp(self): - super(ManufacturerTest, self).setUp() + super().setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -824,7 +820,7 @@ class DeviceTypeTest(APITestCase): def setUp(self): - super(DeviceTypeTest, self).setUp() + super().setUp() self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') @@ -940,7 +936,7 @@ class ConsolePortTemplateTest(APITestCase): def setUp(self): - super(ConsolePortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1040,7 +1036,7 @@ class ConsoleServerPortTemplateTest(APITestCase): def setUp(self): - super(ConsoleServerPortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1140,7 +1136,7 @@ class PowerPortTemplateTest(APITestCase): def setUp(self): - super(PowerPortTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1240,7 +1236,7 @@ class PowerOutletTemplateTest(APITestCase): def setUp(self): - super(PowerOutletTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1340,7 +1336,7 @@ class InterfaceTemplateTest(APITestCase): def setUp(self): - super(InterfaceTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1440,7 +1436,7 @@ class DeviceBayTemplateTest(APITestCase): def setUp(self): - super(DeviceBayTemplateTest, self).setUp() + super().setUp() self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype = DeviceType.objects.create( @@ -1540,7 +1536,7 @@ class DeviceRoleTest(APITestCase): def setUp(self): - super(DeviceRoleTest, self).setUp() + super().setUp() self.devicerole1 = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -1654,7 +1650,7 @@ class PlatformTest(APITestCase): def setUp(self): - super(PlatformTest, self).setUp() + super().setUp() self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') @@ -1755,7 +1751,7 @@ class DeviceTest(APITestCase): def setUp(self): - super(DeviceTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -1917,7 +1913,7 @@ class ConsolePortTest(APITestCase): def setUp(self): - super(ConsolePortTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -1955,7 +1951,7 @@ class ConsolePortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_consoleport(self): @@ -2007,7 +2003,6 @@ class ConsolePortTest(APITestCase): data = { 'device': self.device.pk, 'name': 'Test Console Port X', - 'cs_port': consoleserverport.pk, } url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) @@ -2017,7 +2012,6 @@ class ConsolePortTest(APITestCase): self.assertEqual(ConsolePort.objects.count(), 3) consoleport1 = ConsolePort.objects.get(pk=response.data['id']) self.assertEqual(consoleport1.name, data['name']) - self.assertEqual(consoleport1.cs_port_id, data['cs_port']) def test_delete_consoleport(self): @@ -2032,12 +2026,12 @@ class ConsoleServerPortTest(APITestCase): def setUp(self): - super(ConsoleServerPortTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_console_server=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2070,7 +2064,7 @@ class ConsoleServerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_consoleserverport(self): @@ -2143,7 +2137,7 @@ class PowerPortTest(APITestCase): def setUp(self): - super(PowerPortTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2181,7 +2175,7 @@ class PowerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_powerport(self): @@ -2233,7 +2227,6 @@ class PowerPortTest(APITestCase): data = { 'device': self.device.pk, 'name': 'Test Power Port X', - 'power_outlet': poweroutlet.pk, } url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) @@ -2243,7 +2236,6 @@ class PowerPortTest(APITestCase): self.assertEqual(PowerPort.objects.count(), 3) powerport1 = PowerPort.objects.get(pk=response.data['id']) self.assertEqual(powerport1.name, data['name']) - self.assertEqual(powerport1.power_outlet_id, data['power_outlet']) def test_delete_powerport(self): @@ -2258,12 +2250,12 @@ class PowerOutletTest(APITestCase): def setUp(self): - super(PowerOutletTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_pdu=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2296,7 +2288,7 @@ class PowerOutletTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_poweroutlet(self): @@ -2369,12 +2361,12 @@ class InterfaceTest(APITestCase): def setUp(self): - super(InterfaceTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' @@ -2395,6 +2387,7 @@ class InterfaceTest(APITestCase): url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.interface1.name) def test_get_interface_graphs(self): @@ -2432,7 +2425,7 @@ class InterfaceTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'is_connected', 'name', 'url'] + ['cable', 'connection_status', 'device', 'id', 'name', 'url'] ) def test_create_interface(self): @@ -2567,7 +2560,7 @@ class DeviceBayTest(APITestCase): def setUp(self): - super(DeviceBayTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2690,7 +2683,7 @@ class InventoryItemTest(APITestCase): def setUp(self): - super(InventoryItemTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -2802,228 +2795,516 @@ class InventoryItemTest(APITestCase): self.assertEqual(InventoryItem.objects.count(), 2) -class ConsoleConnectionTest(APITestCase): +class CableTest(APITestCase): def setUp(self): - super(ConsoleConnectionTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') - manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) devicerole = DeviceRole.objects.create( name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - device1 = Device.objects.create( + self.device1 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site ) - device2 = Device.objects.create( + self.device2 = Device.objects.create( device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site ) - cs_port1 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 1') - cs_port2 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 2') - cs_port3 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 3') - ConsolePort.objects.create( - device=device2, cs_port=cs_port1, name='Test Console Port 1', connection_status=True - ) - ConsolePort.objects.create( - device=device2, cs_port=cs_port2, name='Test Console Port 2', connection_status=True - ) - ConsolePort.objects.create( - device=device2, cs_port=cs_port3, name='Test Console Port 3', connection_status=True - ) + for device in [self.device1, self.device2]: + for i in range(0, 10): + Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save() - def test_list_consoleconnections(self): + self.cable1 = Cable( + termination_a=self.device1.interfaces.get(name='eth0'), + termination_b=self.device2.interfaces.get(name='eth0'), + label='Test Cable 1' + ) + self.cable1.save() + self.cable2 = Cable( + termination_a=self.device1.interfaces.get(name='eth1'), + termination_b=self.device2.interfaces.get(name='eth1'), + label='Test Cable 2' + ) + self.cable2.save() + self.cable3 = Cable( + termination_a=self.device1.interfaces.get(name='eth2'), + termination_b=self.device2.interfaces.get(name='eth2'), + label='Test Cable 3' + ) + self.cable3.save() - url = reverse('dcim-api:consoleconnections-list') + def test_get_cable(self): + + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], self.cable1.pk) + + def test_list_cables(self): + + url = reverse('dcim-api:cable-list') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + def test_create_cable(self): -class PowerConnectionTest(APITestCase): - - def setUp(self): - - super(PowerConnectionTest, self).setUp() - - site = Site.objects.create(name='Test Site 1', slug='test-site-1') - manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' - ) - devicerole = DeviceRole.objects.create( - name='Test Device Role 1', slug='test-device-role-1', color='ff0000' - ) - device1 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site - ) - device2 = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site - ) - power_outlet1 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 1') - power_outlet2 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 2') - power_outlet3 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 3') - PowerPort.objects.create( - device=device2, power_outlet=power_outlet1, name='Test Power Port 1', connection_status=True - ) - PowerPort.objects.create( - device=device2, power_outlet=power_outlet2, name='Test Power Port 2', connection_status=True - ) - PowerPort.objects.create( - device=device2, power_outlet=power_outlet3, name='Test Power Port 3', connection_status=True - ) - - def test_list_powerconnections(self): - - url = reverse('dcim-api:powerconnections-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - -class InterfaceConnectionTest(APITestCase): - - def setUp(self): - - super(InterfaceConnectionTest, self).setUp() - - site = Site.objects.create(name='Test Site 1', slug='test-site-1') - manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') - devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' - ) - devicerole = DeviceRole.objects.create( - name='Test Device Role 1', slug='test-device-role-1', color='ff0000' - ) - self.device = Device.objects.create( - device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site - ) - self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') - self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') - self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') - self.interface4 = Interface.objects.create(device=self.device, name='Test Interface 4') - self.interface5 = Interface.objects.create(device=self.device, name='Test Interface 5') - self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6') - self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7') - self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8') - self.interface9 = Interface.objects.create(device=self.device, name='Test Interface 9') - self.interface10 = Interface.objects.create(device=self.device, name='Test Interface 10') - self.interface11 = Interface.objects.create(device=self.device, name='Test Interface 11') - self.interface12 = Interface.objects.create(device=self.device, name='Test Interface 12') - self.interfaceconnection1 = InterfaceConnection.objects.create( - interface_a=self.interface1, interface_b=self.interface2 - ) - self.interfaceconnection2 = InterfaceConnection.objects.create( - interface_a=self.interface3, interface_b=self.interface4 - ) - self.interfaceconnection3 = InterfaceConnection.objects.create( - interface_a=self.interface5, interface_b=self.interface6 - ) - - def test_get_interfaceconnection(self): - - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['interface_a']['id'], self.interfaceconnection1.interface_a_id) - self.assertEqual(response.data['interface_b']['id'], self.interfaceconnection1.interface_b_id) - - def test_list_interfaceconnections(self): - - url = reverse('dcim-api:interfaceconnection-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 3) - - def test_list_interfaceconnections_brief(self): - - url = reverse('dcim-api:interfaceconnection-list') - response = self.client.get('{}?brief=1'.format(url), **self.header) - - self.assertEqual( - sorted(response.data['results'][0]), - ['connection_status', 'id', 'url'] - ) - - def test_create_interfaceconnection(self): - + interface_a = self.device1.interfaces.get(name='eth3') + interface_b = self.device2.interfaces.get(name='eth3') data = { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface_a.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface_b.pk, + 'status': CONNECTION_STATUS_PLANNED, + 'label': 'Test Cable 4', } - url = reverse('dcim-api:interfaceconnection-list') + url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(InterfaceConnection.objects.count(), 4) - interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id']) - self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) - self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b']) + self.assertEqual(Cable.objects.count(), 4) + cable4 = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable4.termination_a, interface_a) + self.assertEqual(cable4.termination_b, interface_b) + self.assertEqual(cable4.status, data['status']) + self.assertEqual(cable4.label, data['label']) - def test_create_interfaceconnection_bulk(self): + def test_create_cable_bulk(self): data = [ { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth3').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth3').pk, + 'label': 'Test Cable 4', }, { - 'interface_a': self.interface9.pk, - 'interface_b': self.interface10.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth4').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth4').pk, + 'label': 'Test Cable 5', }, { - 'interface_a': self.interface11.pk, - 'interface_b': self.interface12.pk, + 'termination_a_type': 'dcim.interface', + 'termination_a_id': self.device1.interfaces.get(name='eth5').pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': self.device2.interfaces.get(name='eth5').pk, + 'label': 'Test Cable 6', }, ] - url = reverse('dcim-api:interfaceconnection-list') + url = reverse('dcim-api:cable-list') response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(InterfaceConnection.objects.count(), 6) - for i in range(0, 3): - self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a']) - self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b']) + self.assertEqual(Cable.objects.count(), 6) + self.assertEqual(response.data[0]['label'], data[0]['label']) + self.assertEqual(response.data[1]['label'], data[1]['label']) + self.assertEqual(response.data[2]['label'], data[2]['label']) - def test_update_interfaceconnection(self): - - new_connection_status = not self.interfaceconnection1.connection_status + def test_update_cable(self): data = { - 'interface_a': self.interface7.pk, - 'interface_b': self.interface8.pk, - 'connection_status': new_connection_status, + 'label': 'Test Cable X', + 'status': CONNECTION_STATUS_CONNECTED, } - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) - response = self.client.put(url, data, format='json', **self.header) + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) + response = self.client.patch(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(InterfaceConnection.objects.count(), 3) - interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id']) - self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a']) - self.assertEqual(interfaceconnection1.interface_b_id, data['interface_b']) - self.assertEqual(interfaceconnection1.connection_status, data['connection_status']) + self.assertEqual(Cable.objects.count(), 3) + cable1 = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable1.status, data['status']) + self.assertEqual(cable1.label, data['label']) - def test_delete_interfaceconnection(self): + def test_delete_cable(self): - url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk}) response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(InterfaceConnection.objects.count(), 2) + self.assertEqual(Cable.objects.count(), 2) + + +class ConnectionTest(APITestCase): + + def setUp(self): + + super().setUp() + + self.site = Site.objects.create( + name='Test Site 1', slug='test-site-1' + ) + manufacturer = Manufacturer.objects.create( + name='Test Manufacturer 1', slug='test-manufacturer-1' + ) + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site + ) + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site + ) + self.panel1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=self.site + ) + self.panel2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=self.site + ) + + def test_create_direct_console_connection(self): + + consoleport1 = ConsolePort.objects.create( + device=self.device1, name='Test Console Port 1' + ) + consoleserverport1 = ConsoleServerPort.objects.create( + device=self.device2, name='Test Console Server Port 1' + ) + + data = { + 'termination_a_type': 'dcim.consoleport', + 'termination_a_id': consoleport1.pk, + 'termination_b_type': 'dcim.consoleserverport', + 'termination_b_id': consoleserverport1.pk, + } + + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) + + cable = Cable.objects.get(pk=response.data['id']) + consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + + self.assertEqual(cable.termination_a, consoleport1) + self.assertEqual(cable.termination_b, consoleserverport1) + self.assertEqual(consoleport1.cable, cable) + self.assertEqual(consoleserverport1.cable, cable) + self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) + self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) + + def test_create_patched_console_connection(self): + + consoleport1 = ConsolePort.objects.create( + device=self.device1, name='Test Console Port 1' + ) + consoleserverport1 = ConsoleServerPort.objects.create( + device=self.device2, name='Test Console Server Port 1' + ) + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C + ) + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 + ) + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C + ) + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 + ) + + url = reverse('dcim-api:cable-list') + cables = [ + # Console port to panel1 front + { + 'termination_a_type': 'dcim.consoleport', + 'termination_a_id': consoleport1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, + }, + # Panel1 rear to panel2 rear + { + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, + }, + # Panel2 front to console server port + { + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'dcim.consoleserverport', + 'termination_b_id': consoleserverport1.pk, + }, + ] + + for data in cables: + + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) + + consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk) + consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk) + self.assertEqual(consoleport1.connected_endpoint, consoleserverport1) + self.assertEqual(consoleserverport1.connected_endpoint, consoleport1) + + def test_create_direct_power_connection(self): + + powerport1 = PowerPort.objects.create( + device=self.device1, name='Test Power Port 1' + ) + poweroutlet1 = PowerOutlet.objects.create( + device=self.device2, name='Test Power Outlet 1' + ) + + data = { + 'termination_a_type': 'dcim.powerport', + 'termination_a_id': powerport1.pk, + 'termination_b_type': 'dcim.poweroutlet', + 'termination_b_id': poweroutlet1.pk, + } + + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) + + cable = Cable.objects.get(pk=response.data['id']) + powerport1 = PowerPort.objects.get(pk=powerport1.pk) + poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk) + + self.assertEqual(cable.termination_a, powerport1) + self.assertEqual(cable.termination_b, poweroutlet1) + self.assertEqual(powerport1.cable, cable) + self.assertEqual(poweroutlet1.cable, cable) + self.assertEqual(powerport1.connected_endpoint, poweroutlet1) + self.assertEqual(poweroutlet1.connected_endpoint, powerport1) + + # Note: Power connections via patch ports are not supported. + + def test_create_direct_interface_connection(self): + + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + interface2 = Interface.objects.create( + device=self.device2, name='Test Interface 2' + ) + + data = { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface2.pk, + } + + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) + + cable = Cable.objects.get(pk=response.data['id']) + interface1 = Interface.objects.get(pk=interface1.pk) + interface2 = Interface.objects.get(pk=interface2.pk) + + self.assertEqual(cable.termination_a, interface1) + self.assertEqual(cable.termination_b, interface2) + self.assertEqual(interface1.cable, cable) + self.assertEqual(interface2.cable, cable) + self.assertEqual(interface1.connected_endpoint, interface2) + self.assertEqual(interface2.connected_endpoint, interface1) + + def test_create_patched_interface_connection(self): + + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + interface2 = Interface.objects.create( + device=self.device2, name='Test Interface 2' + ) + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C + ) + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 + ) + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C + ) + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 + ) + + url = reverse('dcim-api:cable-list') + cables = [ + # Interface1 to panel1 front + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, + }, + # Panel1 rear to panel2 rear + { + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, + }, + # Panel2 front to interface2 + { + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'dcim.interface', + 'termination_b_id': interface2.pk, + }, + ] + + for data in cables: + + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) + + interface1 = Interface.objects.get(pk=interface1.pk) + interface2 = Interface.objects.get(pk=interface2.pk) + self.assertEqual(interface1.connected_endpoint, interface2) + self.assertEqual(interface2.connected_endpoint, interface1) + + def test_create_direct_circuittermination_connection(self): + + provider = Provider.objects.create( + name='Test Provider 1', slug='test-provider-1' + ) + circuittype = CircuitType.objects.create( + name='Test Circuit Type 1', slug='test-circuit-type-1' + ) + circuit = Circuit.objects.create( + provider=provider, type=circuittype, cid='Test Circuit 1' + ) + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + circuittermination1 = CircuitTermination.objects.create( + circuit=circuit, term_side='A', site=self.site, port_speed=10000 + ) + + data = { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'circuits.circuittermination', + 'termination_b_id': circuittermination1.pk, + } + + url = reverse('dcim-api:cable-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Cable.objects.count(), 1) + + cable = Cable.objects.get(pk=response.data['id']) + interface1 = Interface.objects.get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + + self.assertEqual(cable.termination_a, interface1) + self.assertEqual(cable.termination_b, circuittermination1) + self.assertEqual(interface1.cable, cable) + self.assertEqual(circuittermination1.cable, cable) + self.assertEqual(interface1.connected_endpoint, circuittermination1) + self.assertEqual(circuittermination1.connected_endpoint, interface1) + + def test_create_patched_circuittermination_connection(self): + + provider = Provider.objects.create( + name='Test Provider 1', slug='test-provider-1' + ) + circuittype = CircuitType.objects.create( + name='Test Circuit Type 1', slug='test-circuit-type-1' + ) + circuit = Circuit.objects.create( + provider=provider, type=circuittype, cid='Test Circuit 1' + ) + interface1 = Interface.objects.create( + device=self.device1, name='Test Interface 1' + ) + circuittermination1 = CircuitTermination.objects.create( + circuit=circuit, term_side='A', site=self.site, port_speed=10000 + ) + rearport1 = RearPort.objects.create( + device=self.panel1, name='Test Rear Port 1', type=PORT_TYPE_8P8C + ) + frontport1 = FrontPort.objects.create( + device=self.panel1, name='Test Front Port 1', type=PORT_TYPE_8P8C, rear_port=rearport1 + ) + rearport2 = RearPort.objects.create( + device=self.panel2, name='Test Rear Port 2', type=PORT_TYPE_8P8C + ) + frontport2 = FrontPort.objects.create( + device=self.panel2, name='Test Front Port 2', type=PORT_TYPE_8P8C, rear_port=rearport2 + ) + + url = reverse('dcim-api:cable-list') + cables = [ + # Interface to panel1 front + { + 'termination_a_type': 'dcim.interface', + 'termination_a_id': interface1.pk, + 'termination_b_type': 'dcim.frontport', + 'termination_b_id': frontport1.pk, + }, + # Panel1 rear to panel2 rear + { + 'termination_a_type': 'dcim.rearport', + 'termination_a_id': rearport1.pk, + 'termination_b_type': 'dcim.rearport', + 'termination_b_id': rearport2.pk, + }, + # Panel2 front to circuit termination + { + 'termination_a_type': 'dcim.frontport', + 'termination_a_id': frontport2.pk, + 'termination_b_type': 'circuits.circuittermination', + 'termination_b_id': circuittermination1.pk, + }, + ] + + for data in cables: + + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + cable = Cable.objects.get(pk=response.data['id']) + self.assertEqual(cable.termination_a.cable, cable) + self.assertEqual(cable.termination_b.cable, cable) + + interface1 = Interface.objects.get(pk=interface1.pk) + circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk) + self.assertEqual(interface1.connected_endpoint, circuittermination1) + self.assertEqual(circuittermination1.connected_endpoint, interface1) class ConnectedDeviceTest(APITestCase): def setUp(self): - super(ConnectedDeviceTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') @@ -3048,7 +3329,9 @@ class ConnectedDeviceTest(APITestCase): ) self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') - InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2) + + cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + cable.save() def test_get_connected_device(self): @@ -3063,7 +3346,7 @@ class VirtualChassisTest(APITestCase): def setUp(self): - super(VirtualChassisTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site', slug='test-site') manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') @@ -3150,7 +3433,7 @@ class VirtualChassisTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'url'] + ['id', 'master', 'url'] ) def test_create_virtualchassis(self): diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index c8d438728..2f333ea69 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.test import TestCase from dcim.forms import * diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 5b2cdbd51..757af61f4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,7 +1,6 @@ -from __future__ import unicode_literals - from django.test import TestCase +from dcim.constants import * from dcim.models import * @@ -153,110 +152,196 @@ class RackTestCase(TestCase): self.assertTrue(pdu) -class InterfaceTestCase(TestCase): +class CableTestCase(TestCase): def setUp(self): - self.site = Site.objects.create( - name='TestSite1', - slug='my-test-site' + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) - self.rack = Rack.objects.create( - name='TestRack1', - facility_id='A101', - site=self.site, - u_height=42 + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site + ) + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site + ) + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + self.cable.save() + + self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') + 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.device_type = DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' + def test_cable_creation(self): + """ + When a new Cable is created, it must be cached on either termination point. + """ + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(self.cable.termination_a, interface1) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertEqual(self.cable.termination_b, interface2) + + def test_cable_deletion(self): + """ + When a Cable is deleted, the `cable` field on its termination points must be nullified. + """ + self.cable.delete() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.cable) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertIsNone(interface2.cable) + + def test_cabletermination_deletion(self): + """ + When a CableTermination object is deleted, its attached Cable (if any) must also be deleted. + """ + self.interface1.delete() + cable = Cable.objects.filter(pk=self.cable.pk).first() + self.assertIsNone(cable) + + def test_cable_validates_compatibale_types(self): + """ + The clean method should have a check to ensure only compatiable port types can be connected by a cable + """ + # An interface cannot be connected to a power port + cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_have_the_same_terminination_on_both_ends(self): + """ + A cable cannot be made with the same A and B side terminations + """ + cable = Cable(termination_a=self.interface1, termination_b=self.interface1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self): + """ + A cable cannot connect a front port to its sorresponding rear port + """ + cable = Cable(termination_a=self.front_port, termination_b=self.rear_port) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_be_connected_to_an_existing_connection(self): + """ + Either side of a cable cannot be terminated when that side aready has a connection + """ + # Try to create a cable with the same interface terminations + cable = Cable(termination_a=self.interface2, termination_b=self.interface1) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_cannot_connect_to_a_virtual_inteface(self): + """ + A cable connection cannot include a virtual interface + """ + virtual_interface = Interface(device=self.device1, name="V1", form_factor=0) + cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) + with self.assertRaises(ValidationError): + cable.clean() + + +class CablePathTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' ) - self.role = DeviceRole.objects.create( - name='Switch', - slug='switch', + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site + ) + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + self.panel1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 1', site=site + ) + self.panel2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Panel 2', site=site + ) + self.rear_port1 = RearPort.objects.create( + device=self.panel1, name='Rear Port 1', type=PORT_TYPE_8P8C + ) + self.front_port1 = FrontPort.objects.create( + device=self.panel1, name='Front Port 1', type=PORT_TYPE_8P8C, rear_port=self.rear_port1 + ) + self.rear_port2 = RearPort.objects.create( + device=self.panel2, name='Rear Port 2', type=PORT_TYPE_8P8C + ) + self.front_port2 = FrontPort.objects.create( + device=self.panel2, name='Front Port 2', type=PORT_TYPE_8P8C, rear_port=self.rear_port2 ) - def test_interface_order_natural(self): - device1 = Device.objects.create( - name='TestSwitch1', - device_type=self.device_type, - device_role=self.role, - site=self.site, - rack=self.rack, - position=10, - face=RACK_FACE_REAR, - ) - interface1 = Interface.objects.create( - device=device1, - name='Ethernet1/3/1' - ) - interface2 = Interface.objects.create( - device=device1, - name='Ethernet1/5/1' - ) - interface3 = Interface.objects.create( - device=device1, - name='Ethernet1/4' - ) - interface4 = Interface.objects.create( - device=device1, - name='Ethernet1/3/2/4' - ) - interface5 = Interface.objects.create( - device=device1, - name='Ethernet1/3/2/1' - ) - interface6 = Interface.objects.create( - device=device1, - name='Loopback1' - ) + def test_path_completion(self): - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface1, interface5, interface4, interface3, interface2, interface6] - ) + # First segment + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) + cable1.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) - def test_interface_order_natural_subinterfaces(self): - device1 = Device.objects.create( - name='TestSwitch1', - device_type=self.device_type, - device_role=self.role, - site=self.site, - rack=self.rack, - position=10, - face=RACK_FACE_REAR, - ) - interface1 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/3' - ) - interface2 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/2.2' - ) - interface3 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0.120' - ) - interface4 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/0' - ) - interface5 = Interface.objects.create( - device=device1, - name='GigabitEthernet0/0/1.117' - ) - interface6 = Interface.objects.create( - device=device1, - name='GigabitEthernet0' - ) - self.assertEqual( - list(Interface.objects.all().order_naturally()), - [interface4, interface3, interface5, interface2, interface1, interface6] - ) + # Second segment + cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable2.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) + + # Third segment + cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2, status=CONNECTION_STATUS_PLANNED) + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED) + + # Switch third segment from planned to connected + cable3.status = CONNECTION_STATUS_CONNECTED + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED) + + def test_path_teardown(self): + + # Build the path + cable1 = Cable(termination_a=self.interface1, termination_b=self.front_port1) + cable1.save() + cable2 = Cable(termination_a=self.rear_port1, termination_b=self.rear_port2) + cable2.save() + cable3 = Cable(termination_a=self.front_port2, termination_b=self.interface2) + cable3.save() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertEqual(interface1.connected_endpoint, self.interface2) + self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED) + + # Remove a cable + cable2.delete() + interface1 = Interface.objects.get(pk=self.interface1.pk) + self.assertIsNone(interface1.connected_endpoint) + self.assertIsNone(interface1.connection_status) + interface2 = Interface.objects.get(pk=self.interface2.pk) + self.assertIsNone(interface2.connected_endpoint) + self.assertIsNone(interface2.connection_status) diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py new file mode 100644 index 000000000..d4dca43d7 --- /dev/null +++ b/netbox/dcim/tests/test_natural_ordering.py @@ -0,0 +1,157 @@ +from django.test import TestCase + +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site + + +class NaturalOrderingTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + + def _compare_names(self, queryset, names): + + for i, obj in enumerate(queryset): + self.assertEqual(obj.name, names[i]) + + def test_interface_ordering_numeric(self): + + INTERFACES = ( + '0', + '0.1', + '0.2', + '0.10', + '0.100', + '0:1', + '0:1.1', + '0:1.2', + '0:1.10', + '0:2', + '0:2.1', + '0:2.2', + '0:2.10', + '1', + '1.1', + '1.2', + '1.10', + '1.100', + '1:1', + '1:1.1', + '1:1.2', + '1:1.10', + '1:2', + '1:2.1', + '1:2.2', + '1:2.10', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_linux(self): + + INTERFACES = ( + 'eth0', + 'eth0.1', + 'eth0.2', + 'eth0.10', + 'eth0.100', + 'eth1', + 'eth1.1', + 'eth1.2', + 'eth1.100', + 'lo0', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_junos(self): + + INTERFACES = ( + 'xe-0/0/0', + 'xe-0/0/1', + 'xe-0/0/2', + 'xe-0/0/3', + 'xe-0/1/0', + 'xe-0/1/1', + 'xe-0/1/2', + 'xe-0/1/3', + 'xe-1/0/0', + 'xe-1/0/1', + 'xe-1/0/2', + 'xe-1/0/3', + 'xe-1/1/0', + 'xe-1/1/1', + 'xe-1/1/2', + 'xe-1/1/3', + 'xe-2/0/0.1', + 'xe-2/0/0.2', + 'xe-2/0/0.10', + 'xe-2/0/0.11', + 'xe-2/0/0.100', + 'xe-3/0/0:1', + 'xe-3/0/0:2', + 'xe-3/0/0:10', + 'xe-3/0/0:11', + 'xe-3/0/0:100', + 'xe-10/1/0', + 'xe-10/1/1', + 'xe-10/1/2', + 'xe-10/1/3', + 'ae1', + 'ae2', + 'ae10.1', + 'ae10.10', + 'irb.1', + 'irb.2', + 'irb.10', + 'irb.100', + 'lo0', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) + + def test_interface_ordering_ios(self): + + INTERFACES = ( + 'GigabitEthernet0/1', + 'GigabitEthernet0/2', + 'GigabitEthernet0/10', + 'TenGigabitEthernet0/20', + 'TenGigabitEthernet0/21', + 'GigabitEthernet1/1', + 'GigabitEthernet1/2', + 'GigabitEthernet1/10', + 'TenGigabitEthernet1/20', + 'TenGigabitEthernet1/21', + 'FastEthernet1', + 'FastEthernet2', + 'FastEthernet10', + ) + + for name in INTERFACES: + iface = Interface(device=self.device, name=name) + iface.save() + + self._compare_names(Interface.objects.filter(device=self.device), INTERFACES) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7345cdacd..dc1fbbf39 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView, ImageAttachmentEditView @@ -7,8 +5,8 @@ from ipam.views import ServiceCreateView from secrets.views import secret_add from . import views from .models import ( - Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, - Region, Site, VirtualChassis, + Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, + PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) app_name = 'dcim' @@ -111,6 +109,14 @@ urlpatterns = [ url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), + # Front port templates + url(r'^device-types/(?P\d+)/front-ports/add/$', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), + url(r'^device-types/(?P\d+)/front-ports/delete/$', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), + + # Rear port templates + url(r'^device-types/(?P\d+)/rear-ports/add/$', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), + url(r'^device-types/(?P\d+)/rear-ports/delete/$', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), + # Device bay templates url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), url(r'^device-types/(?P\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), @@ -155,56 +161,78 @@ urlpatterns = [ url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'), - url(r'^console-ports/(?P\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'), + url(r'^console-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + url(r'^console-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), # Console server ports url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), - url(r'^devices/(?P\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'), - url(r'^console-server-ports/(?P\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'), + url(r'^console-server-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + url(r'^console-server-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), # Power ports url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'), - url(r'^power-ports/(?P\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'), + url(r'^power-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + url(r'^power-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), # Power outlets url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), - url(r'^devices/(?P\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'), - url(r'^power-outlets/(?P\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'), + url(r'^power-outlets/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + url(r'^power-outlets/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), # Interfaces url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - url(r'^devices/(?P\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), - url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), + url(r'^interfaces/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), url(r'^interfaces/(?P\d+)/$', views.InterfaceView.as_view(), name='interface'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), + url(r'^interfaces/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + + # Front ports + # url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), + url(r'^devices/(?P\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'), + url(r'^devices/(?P\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + url(r'^front-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + url(r'^front-ports/(?P\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'), + url(r'^front-ports/(?P\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + url(r'^front-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + + # Rear ports + # url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + url(r'^devices/(?P\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'), + url(r'^devices/(?P\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + url(r'^rear-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + url(r'^rear-ports/(?P\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'), + url(r'^rear-ports/(?P\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'), + url(r'^rear-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), # Device bays url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), @@ -225,13 +253,20 @@ urlpatterns = [ url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), - # Console/power/interface connections + # Cables + url(r'^cables/$', views.CableListView.as_view(), name='cable_list'), + url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'), + url(r'^cables/edit/$', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + url(r'^cables/delete/$', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + url(r'^cables/(?P\d+)/$', views.CableView.as_view(), name='cable'), + url(r'^cables/(?P\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'), + url(r'^cables/(?P\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'), + url(r'^cables/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + + # Console/power/interface connections (read-only) url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'), url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'), url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), - url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), # Virtual chassis url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 91b2a25a4..632b58d23 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,41 +1,34 @@ -from __future__ import unicode_literals - -from operator import attrgetter - from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction -from django.db.models import Count, Q +from django.db.models import Count, F from django.forms import modelformset_factory -from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import urlencode from django.utils.safestring import mark_safe from django.views.generic import View -from natsort import natsorted from circuits.models import Circuit from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.views import ObjectConfigContextView -from ipam.models import Prefix, Service, VLAN +from ipam.models import Prefix, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator +from utilities.utils import csv_format from utilities.views import ( BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables -from .constants import CONNECTION_STATUS_CONNECTED from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VirtualChassis, + Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, + InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) @@ -81,7 +74,7 @@ class BulkRenameView(GetReturnURLMixin, View): }) -class BulkDisconnectView(View): +class BulkDisconnectView(GetReturnURLMixin, View): """ An extendable view for disconnection console/power/interface components in bulk. """ @@ -89,22 +82,30 @@ class BulkDisconnectView(View): form = None template_name = 'dcim/bulk_disconnect.html' - def disconnect_objects(self, objects): - raise NotImplementedError() + def post(self, request): - def post(self, request, pk): - - device = get_object_or_404(Device, pk=pk) selected_objects = [] + return_url = self.get_return_url(request) if '_confirm' in request.POST: form = self.form(request.POST) + if form.is_valid(): - count = self.disconnect_objects(form.cleaned_data['pk']) - messages.success(request, "Disconnected {} {} on {}".format( - count, self.model._meta.verbose_name_plural, device + + with transaction.atomic(): + + count = 0 + for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']): + if obj.cable is None: + continue + obj.cable.delete() + count += 1 + + messages.success(request, "Disconnected {} {}".format( + count, self.model._meta.verbose_name_plural )) - return redirect(device.get_absolute_url()) + + return redirect(return_url) else: form = self.form(initial={'pk': request.POST.getlist('pk')}) @@ -112,10 +113,9 @@ class BulkDisconnectView(View): return render(request, self.template_name, { 'form': form, - 'device': device, 'obj_type_plural': self.model._meta.verbose_name_plural, 'selected_objects': selected_objects, - 'return_url': device.get_absolute_url(), + 'return_url': return_url, }) @@ -405,7 +405,7 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rack' model_form = forms.RackCSVForm - table = tables.RackImportTable + table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -540,29 +540,35 @@ class DeviceTypeView(View): # Component tables consoleport_table = tables.ConsolePortTemplateTable( - natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + ConsolePortTemplate.objects.filter(device_type=devicetype), orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( - natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + ConsoleServerPortTemplate.objects.filter(device_type=devicetype), orderable=False ) powerport_table = tables.PowerPortTemplateTable( - natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + PowerPortTemplate.objects.filter(device_type=devicetype), orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( - natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + PowerOutletTemplate.objects.filter(device_type=devicetype), orderable=False ) interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally( - devicetype.interface_ordering - ).filter(device_type=devicetype)), + list(InterfaceTemplate.objects.filter(device_type=devicetype)), + orderable=False + ) + front_port_table = tables.FrontPortTemplateTable( + FrontPortTemplate.objects.filter(device_type=devicetype), + orderable=False + ) + rear_port_table = tables.RearPortTemplateTable( + RearPortTemplate.objects.filter(device_type=devicetype), orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( - natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + DeviceBayTemplate.objects.filter(device_type=devicetype), orderable=False ) if request.user.has_perm('dcim.change_devicetype'): @@ -571,6 +577,8 @@ class DeviceTypeView(View): powerport_table.columns.show('pk') poweroutlet_table.columns.show('pk') interface_table.columns.show('pk') + front_port_table.columns.show('pk') + rear_port_table.columns.show('pk') devicebay_table.columns.show('pk') return render(request, 'dcim/devicetype.html', { @@ -580,6 +588,8 @@ class DeviceTypeView(View): 'powerport_table': powerport_table, 'poweroutlet_table': poweroutlet_table, 'interface_table': interface_table, + 'front_port_table': front_port_table, + 'rear_port_table': rear_port_table, 'devicebay_table': devicebay_table, }) @@ -723,6 +733,40 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTemplateTable +class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_frontporttemplate' + parent_model = DeviceType + parent_field = 'device_type' + model = FrontPortTemplate + form = forms.FrontPortTemplateCreateForm + model_form = forms.FrontPortTemplateForm + template_name = 'dcim/device_component_add.html' + + +class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + queryset = FrontPortTemplate.objects.all() + parent_model = DeviceType + table = tables.FrontPortTemplateTable + + +class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_rearporttemplate' + parent_model = DeviceType + parent_field = 'device_type' + model = RearPortTemplate + form = forms.RearPortTemplateCreateForm + model_form = forms.RearPortTemplateForm + template_name = 'dcim/device_component_add.html' + + +class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + queryset = RearPortTemplate.objects.all() + parent_model = DeviceType + table = tables.RearPortTemplateTable + + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' parent_model = DeviceType @@ -815,8 +859,9 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceListView(ObjectListView): - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', - 'primary_ip4', 'primary_ip6') + queryset = Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' + ) filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm table = tables.DeviceDetailTable @@ -833,44 +878,42 @@ class DeviceView(View): # VirtualChassis members if device.virtual_chassis is not None: - vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis).order_by('vc_position') + vc_members = Device.objects.filter( + virtual_chassis=device.virtual_chassis + ).order_by('vc_position') else: vc_members = [] # Console ports - console_ports = natsorted( - ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') - ) + console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable') # Console server ports - cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = natsorted( - PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') - ) + power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') # Power outlets - power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') + poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') # Interfaces - interfaces = device.vc_interfaces.order_naturally( - device.device_type.interface_ordering - ).select_related( - 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', - 'circuit_termination__circuit__provider' + interfaces = device.vc_interfaces.select_related( + 'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable' ).prefetch_related( - 'tags', 'ip_addresses' + 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags' ) + # Front ports + front_ports = device.frontports.select_related('rear_port', 'cable') + + # Rear ports + rear_ports = device.rearports.select_related('cable') + # Device bays - device_bays = natsorted( - DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), - key=attrgetter('name') - ) + device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer') # Services - services = Service.objects.filter(device=device) + services = device.services.all() # Secrets secrets = device.secrets.all() @@ -890,11 +933,13 @@ class DeviceView(View): return render(request, 'dcim/device.html', { 'device': device, 'console_ports': console_ports, - 'cs_ports': cs_ports, + 'consoleserverports': consoleserverports, 'power_ports': power_ports, - 'power_outlets': power_outlets, + 'poweroutlets': poweroutlets, 'interfaces': interfaces, 'device_bays': device_bays, + 'front_ports': front_ports, + 'rear_ports': rear_ports, 'services': services, 'secrets': secrets, 'vc_members': vc_members, @@ -942,10 +987,8 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.order_naturally( - device.device_type.interface_ordering - ).connectable().select_related( - 'connected_as_a', 'connected_as_b' + interfaces = device.vc_interfaces.connectable().select_related( + '_connected_interface__device' ) return render(request, 'dcim/device_lldp_neighbors.html', { @@ -1049,102 +1092,6 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsolePortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleport' - - def get(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'console_server': request.GET.get('console_server'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/consoleport_connect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - def post(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) - - if form.is_valid(): - - consoleport = form.save() - msg = 'Connected {} {} to {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleport.cs_port.device.get_absolute_url(), - escape(consoleport.cs_port.device), - escape(consoleport.cs_port.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_connect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - -class ConsolePortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleport' - - def get(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = ConfirmationForm() - - if not consoleport.cs_port: - messages.warning( - request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) - ) - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_disconnect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - def post(self, request, pk): - - consoleport = get_object_or_404(ConsolePort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - cs_port = consoleport.cs_port - consoleport.cs_port = None - consoleport.connection_status = None - consoleport.save() - msg = 'Disconnected {} {} from {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - cs_port.device.get_absolute_url(), - escape(cs_port.device), - escape(cs_port.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleport.device.pk) - - return render(request, 'dcim/consoleport_disconnect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) - - class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort @@ -1163,13 +1110,6 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.ConsolePortTable -class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_consoleport' - model_form = forms.ConsoleConnectionCSVForm - table = tables.ConsoleConnectionTable - default_return_url = 'dcim:console_connections_list' - - # # Console server ports # @@ -1184,106 +1124,6 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class ConsoleServerPortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleserverport' - - def get(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = forms.ConsoleServerPortConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/consoleserverport_connect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - def post(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = forms.ConsoleServerPortConnectionForm(request.POST) - - if form.is_valid(): - - consoleport = form.cleaned_data['port'] - consoleport.cs_port = consoleserverport - consoleport.connection_status = form.cleaned_data['connection_status'] - consoleport.save() - msg = 'Connected {} {} to {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleserverport.device.get_absolute_url(), - escape(consoleserverport.device), - escape(consoleserverport.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_connect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - -class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_consoleserverport' - - def get(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = ConfirmationForm() - - if not hasattr(consoleserverport, 'connected_console'): - messages.warning( - request, - "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) - ) - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_disconnect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - def post(self, request, pk): - - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - consoleport = consoleserverport.connected_console - consoleport.cs_port = None - consoleport.connection_status = None - consoleport.save() - msg = 'Disconnected {} {} from {} {}'.format( - consoleport.device.get_absolute_url(), - escape(consoleport.device), - escape(consoleport.name), - consoleserverport.device.get_absolute_url(), - escape(consoleserverport.device), - escape(consoleserverport.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=consoleserverport.device.pk) - - return render(request, 'dcim/consoleserverport_disconnect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) - - class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort @@ -1306,9 +1146,6 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec model = ConsoleServerPort form = forms.ConsoleServerPortBulkDisconnectForm - def disconnect_objects(self, cs_ports): - return ConsolePort.objects.filter(cs_port__in=cs_ports).update(cs_port=None, connection_status=None) - class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' @@ -1331,102 +1168,6 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerPortConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_powerport' - - def get(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = forms.PowerPortConnectionForm(instance=powerport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'pdu': request.GET.get('pdu'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/powerport_connect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - def post(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = forms.PowerPortConnectionForm(request.POST, instance=powerport) - - if form.is_valid(): - - powerport = form.save() - msg = 'Connected {} {} to {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - powerport.power_outlet.device.get_absolute_url(), - escape(powerport.power_outlet.device), - escape(powerport.power_outlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_connect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - -class PowerPortDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_powerport' - - def get(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = ConfirmationForm() - - if not powerport.power_outlet: - messages.warning( - request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) - ) - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_disconnect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - def post(self, request, pk): - - powerport = get_object_or_404(PowerPort, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - power_outlet = powerport.power_outlet - powerport.power_outlet = None - powerport.connection_status = None - powerport.save() - msg = 'Disconnected {} {} from {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - power_outlet.device.get_absolute_url(), - escape(power_outlet.device), - escape(power_outlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=powerport.device.pk) - - return render(request, 'dcim/powerport_disconnect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) - - class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort @@ -1445,13 +1186,6 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTable -class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_powerport' - model_form = forms.PowerConnectionCSVForm - table = tables.PowerConnectionTable - default_return_url = 'dcim:power_connections_list' - - # # Power outlets # @@ -1466,104 +1200,6 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class PowerOutletConnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_poweroutlet' - - def get(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = forms.PowerOutletConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, - }) - - return render(request, 'dcim/poweroutlet_connect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - def post(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = forms.PowerOutletConnectionForm(request.POST) - - if form.is_valid(): - powerport = form.cleaned_data['port'] - powerport.power_outlet = poweroutlet - powerport.connection_status = form.cleaned_data['connection_status'] - powerport.save() - msg = 'Connected {} {} to {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - poweroutlet.device.get_absolute_url(), - escape(poweroutlet.device), - escape(poweroutlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_connect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - -class PowerOutletDisconnectView(PermissionRequiredMixin, View): - permission_required = 'dcim.change_poweroutlet' - - def get(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = ConfirmationForm() - - if not hasattr(poweroutlet, 'connected_port'): - messages.warning( - request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) - ) - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_disconnect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - def post(self, request, pk): - - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) - form = ConfirmationForm(request.POST) - - if form.is_valid(): - - powerport = poweroutlet.connected_port - powerport.power_outlet = None - powerport.connection_status = None - powerport.save() - msg = 'Disconnected {} {} from {} {}'.format( - powerport.device.get_absolute_url(), - escape(powerport.device), - escape(powerport.name), - poweroutlet.device.get_absolute_url(), - escape(poweroutlet.device), - escape(poweroutlet.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect('dcim:device', pk=poweroutlet.device.pk) - - return render(request, 'dcim/poweroutlet_disconnect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) - - class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet @@ -1586,11 +1222,6 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView) model = PowerOutlet form = forms.PowerOutletBulkDisconnectForm - def disconnect_objects(self, power_outlets): - return PowerPort.objects.filter(power_outlet__in=power_outlets).update( - power_outlet=None, connection_status=None - ) - class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' @@ -1609,13 +1240,6 @@ class InterfaceView(View): interface = get_object_or_404(Interface, pk=pk) - # Get connected interface - connected_interface = interface.connected_interface - if connected_interface is None and hasattr(interface, 'circuit_termination'): - peer_termination = interface.circuit_termination.get_peer_termination() - if peer_termination is not None: - connected_interface = peer_termination.interface - # Get assigned IP addresses ipaddress_table = InterfaceIPAddressTable( data=interface.ip_addresses.select_related('vrf', 'tenant'), @@ -1638,7 +1262,8 @@ class InterfaceView(View): return render(request, 'dcim/interface.html', { 'interface': interface, - 'connected_interface': connected_interface, + 'connected_interface': interface._connected_interface, + 'connected_circuittermination': interface._connected_circuittermination, 'ipaddress_table': ipaddress_table, 'vlan_table': vlan_table, }) @@ -1672,18 +1297,6 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = Interface -class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): - permission_required = 'dcim.change_interface' - model = Interface - form = forms.InterfaceBulkDisconnectForm - - def disconnect_objects(self, interfaces): - count, _ = InterfaceConnection.objects.filter( - Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces) - ).delete() - return count - - class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' queryset = Interface.objects.all() @@ -1694,10 +1307,16 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): permission_required = 'dcim.change_interface' - queryset = Interface.objects.order_naturally() + queryset = Interface.objects.all() form = forms.InterfaceBulkRenameForm +class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_interface' + model = Interface + form = forms.InterfaceBulkDisconnectForm + + class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' queryset = Interface.objects.all() @@ -1705,6 +1324,94 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTable +# +# Front ports +# + +class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_frontport' + parent_model = Device + parent_field = 'device' + model = FrontPort + form = forms.FrontPortCreateForm + model_form = forms.FrontPortForm + template_name = 'dcim/device_component_add.html' + + +class FrontPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_frontport' + model = FrontPort + model_form = forms.FrontPortForm + + +class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontport' + model = FrontPort + + +class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_frontport' + queryset = FrontPort.objects.all() + form = forms.FrontPortBulkRenameForm + + +class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_frontport' + model = FrontPort + form = forms.FrontPortBulkDisconnectForm + + +class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_frontport' + queryset = FrontPort.objects.all() + parent_model = Device + table = tables.FrontPortTable + + +# +# Rear ports +# + +class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_rearport' + parent_model = Device + parent_field = 'device' + model = RearPort + form = forms.RearPortCreateForm + model_form = forms.RearPortForm + template_name = 'dcim/device_component_add.html' + + +class RearPortEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_rearport' + model = RearPort + model_form = forms.RearPortForm + + +class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearport' + model = RearPort + + +class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_rearport' + queryset = RearPort.objects.all() + form = forms.RearPortBulkRenameForm + + +class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_rearport' + model = RearPort + form = forms.RearPortBulkDisconnectForm + + +class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rearport' + queryset = RearPort.objects.all() + parent_model = Device + table = tables.RearPortTable + + # # Device bays # @@ -1883,112 +1590,97 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # -# Interface connections +# Cables # -class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.add_interfaceconnection' - default_return_url = 'dcim:device_list' +class CableListView(ObjectListView): + queryset = Cable.objects.prefetch_related( + 'termination_a', 'termination_b' + ) + filter = filters.CableFilter + filter_form = forms.CableFilterForm + table = tables.CableTable + template_name = 'dcim/cable_list.html' + + +class CableView(View): def get(self, request, pk): - device = get_object_or_404(Device, pk=pk) - form = forms.InterfaceConnectionForm(device, initial={ - 'interface_a': request.GET.get('interface_a'), - 'site_b': request.GET.get('site_b'), - 'rack_b': request.GET.get('rack_b'), - 'device_b': request.GET.get('device_b'), - 'interface_b': request.GET.get('interface_b'), - }) + cable = get_object_or_404(Cable, pk=pk) - return render(request, 'dcim/interfaceconnection_edit.html', { - 'device': device, - 'form': form, - 'return_url': device.get_absolute_url(), - }) - - def post(self, request, pk): - - device = get_object_or_404(Device, pk=pk) - form = forms.InterfaceConnectionForm(device, request.POST) - - if form.is_valid(): - - interfaceconnection = form.save() - msg = 'Connected {} {} to {} {}'.format( - interfaceconnection.interface_a.device.get_absolute_url(), - escape(interfaceconnection.interface_a.device), - escape(interfaceconnection.interface_a.name), - interfaceconnection.interface_b.device.get_absolute_url(), - escape(interfaceconnection.interface_b.device), - escape(interfaceconnection.interface_b.name), - ) - messages.success(request, mark_safe(msg)) - - if '_addanother' in request.POST: - base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) - device_b = interfaceconnection.interface_b.device - params = urlencode({ - 'rack_b': device_b.rack.pk if device_b.rack else '', - 'device_b': device_b.pk, - }) - return HttpResponseRedirect('{}?{}'.format(base_url, params)) - else: - return redirect('dcim:device', pk=device.pk) - - return render(request, 'dcim/interfaceconnection_edit.html', { - 'device': device, - 'form': form, - 'return_url': device.get_absolute_url(), + return render(request, 'dcim/cable.html', { + 'cable': cable, }) -class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, View): - permission_required = 'dcim.delete_interfaceconnection' - default_return_url = 'dcim:device_list' +class CableTraceView(View): + """ + Trace a cable path beginning from the given termination. + """ - def get(self, request, pk): + def get(self, request, model, pk): - interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) - form = forms.ConfirmationForm() + obj = get_object_or_404(model, pk=pk) - return render(request, 'dcim/interfaceconnection_delete.html', { - 'interfaceconnection': interfaceconnection, - 'form': form, - 'return_url': self.get_return_url(request, interfaceconnection), - }) - - def post(self, request, pk): - - interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) - form = forms.ConfirmationForm(request.POST) - - if form.is_valid(): - interfaceconnection.delete() - msg = 'Disconnected {} {} from {} {}'.format( - interfaceconnection.interface_a.device.get_absolute_url(), - escape(interfaceconnection.interface_a.device), - escape(interfaceconnection.interface_a.name), - interfaceconnection.interface_b.device.get_absolute_url(), - escape(interfaceconnection.interface_b.device), - escape(interfaceconnection.interface_b.name), - ) - messages.success(request, mark_safe(msg)) - - return redirect(self.get_return_url(request, interfaceconnection)) - - return render(request, 'dcim/interfaceconnection_delete.html', { - 'interfaceconnection': interfaceconnection, - 'form': form, - 'return_url': self.get_return_url(request, interfaceconnection), + return render(request, 'dcim/cable_trace.html', { + 'obj': obj, + 'trace': obj.trace(follow_circuits=True), }) -class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'dcim.change_interface' - model_form = forms.InterfaceConnectionCSVForm - table = tables.InterfaceConnectionTable - default_return_url = 'dcim:interface_connections_list' +class CableCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_cable' + model = Cable + model_form = forms.CableCreateForm + template_name = 'dcim/cable_connect.html' + + def alter_obj(self, obj, request, url_args, url_kwargs): + + # Retrieve endpoint A based on the given type and PK + termination_a_type = url_kwargs.get('termination_a_type') + termination_a_id = url_kwargs.get('termination_a_id') + obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + + return obj + + +class CableEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_cable' + model = Cable + model_form = forms.CableForm + template_name = 'dcim/cable_edit.html' + default_return_url = 'dcim:cable_list' + + +class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_cable' + model = Cable + default_return_url = 'dcim:cable_list' + + +class CableBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_cable' + model_form = forms.CableCSVForm + table = tables.CableTable + default_return_url = 'dcim:cable_list' + + +class CableBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_cable' + queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + filter = filters.CableFilter + table = tables.CableTable + form = forms.CableBulkEditForm + default_return_url = 'dcim:cable_list' + + +class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_cable' + queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + filter = filters.CableFilter + table = tables.CableTable + default_return_url = 'dcim:cable_list' # @@ -1996,34 +1688,96 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView # class ConsoleConnectionsListView(ObjectListView): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) \ - .order_by('cs_port__device__name', 'cs_port__name') + queryset = ConsolePort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ).order_by( + 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + ) filter = filters.ConsoleConnectionFilter filter_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/console_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join(['console_server', 'port', 'device', 'console_port', 'connection_status']) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + class PowerConnectionsListView(ObjectListView): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) \ - .order_by('power_outlet__device__name', 'power_outlet__name') + queryset = PowerPort.objects.select_related( + 'device', 'connected_endpoint__device' + ).filter( + connected_endpoint__isnull=False + ).order_by( + 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/power_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join(['pdu', 'outlet', 'device', 'power_port', 'connection_status']) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + class InterfaceConnectionsListView(ObjectListView): - queryset = InterfaceConnection.objects.select_related( - 'interface_a__device', 'interface_b__device' + queryset = Interface.objects.select_related( + 'device', 'cable', '_connected_interface__device' + ).filter( + # Avoid duplicate connections by only selecting the lower PK in a connected pair + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') ).order_by( - 'interface_a__device__name', 'interface_a__name' + 'device' ) filter = filters.InterfaceConnectionFilter filter_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/interface_connections_list.html' + def queryset_to_csv(self): + csv_data = [ + # Headers + ','.join(['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']) + ] + for obj in self.queryset: + csv = csv_format([ + obj.connected_endpoint.device.identifier if obj.connected_endpoint else None, + obj.connected_endpoint.name if obj.connected_endpoint else None, + obj.device.identifier, + obj.name, + obj.get_connection_status_display(), + ]) + csv_data.append(csv) + return csv_data + # # Inventory items diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 0549ce317..e747bf71a 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,12 +1,9 @@ -from __future__ import unicode_literals - from django import forms from django.contrib import admin -from django.utils.safestring import mark_safe from netbox.admin import admin_site from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, Webhook +from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook def order_content_types(field): @@ -31,7 +28,7 @@ class WebhookForm(forms.ModelForm): exclude = [] def __init__(self, *args, **kwargs): - super(WebhookForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) order_content_types(self.fields['obj_type']) @@ -59,7 +56,7 @@ class CustomFieldForm(forms.ModelForm): exclude = [] def __init__(self, *args, **kwargs): - super(CustomFieldForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) order_content_types(self.fields['obj_type']) @@ -99,7 +96,7 @@ class ExportTemplateForm(forms.ModelForm): exclude = [] def __init__(self, *args, **kwargs): - super(ExportTemplateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Format ContentType choices order_content_types(self.fields['content_type']) @@ -122,16 +119,3 @@ class TopologyMapAdmin(admin.ModelAdmin): prepopulated_fields = { 'slug': ['name'], } - - -# -# User actions -# - -@admin.register(UserAction, site=admin_site) -class UserActionAdmin(admin.ModelAdmin): - actions = None - list_display = ['user', 'action', 'content_type', 'object_id', '_message'] - - def _message(self, obj): - return mark_safe(obj.message) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 0497138c4..7bf1c0744 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from datetime import datetime from django.contrib.contenttypes.models import ContentType @@ -107,7 +105,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): custom_fields[cfv.field.name] = cfv.value instance.custom_fields = custom_fields - super(CustomFieldModelSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance is not None: @@ -139,7 +137,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): with transaction.atomic(): - instance = super(CustomFieldModelSerializer, self).create(validated_data) + instance = super().create(validated_data) # Save custom fields if custom_fields is not None: @@ -154,7 +152,7 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): with transaction.atomic(): - instance = super(CustomFieldModelSerializer, self).update(instance, validated_data) + instance = super().update(instance, validated_data) # Save custom fields if custom_fields is not None: diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py new file mode 100644 index 000000000..11367aba9 --- /dev/null +++ b/netbox/extras/api/nested_serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from extras.models import ReportResult + +__all__ = [ + 'NestedReportResultSerializer', +] + + +# +# Reports +# + +class NestedReportResultSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='extras-api:report-detail', + lookup_field='report', + lookup_url_kwarg='pk' + ) + + class Meta: + model = ReportResult + fields = ['url', 'created', 'user', 'failed'] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d0d2c67b0..7643562bb 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,24 +1,23 @@ -from __future__ import unicode_literals - from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from taggit.models import Tag -from dcim.api.serializers import ( +from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, ) from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site -from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction, -) from extras.constants import * -from tenancy.api.serializers import NestedTenantSerializer, NestedTenantGroupSerializer +from extras.models import ( + ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, +) +from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup -from users.api.serializers import NestedUserSerializer +from users.api.nested_serializers import NestedUserSerializer from utilities.api import ( ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer, ) +from .nested_serializers import * # @@ -109,7 +108,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): ) # Enforce model validation - super(ImageAttachmentSerializer, self).validate(data) + super().validate(data) return data @@ -189,18 +188,6 @@ class ReportResultSerializer(serializers.ModelSerializer): fields = ['created', 'user', 'failed', 'data'] -class NestedReportResultSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:report-detail', - lookup_field='report', - lookup_url_kwarg='pk' - ) - - class Meta: - model = ReportResult - fields = ['url', 'created', 'user', 'failed'] - - class ReportSerializer(serializers.Serializer): module = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255) @@ -240,16 +227,3 @@ class ObjectChangeSerializer(serializers.ModelSerializer): context = {'request': self.context['request']} data = serializer(obj.changed_object, context=context).data return data - - -# -# User actions -# - -class UserActionSerializer(serializers.ModelSerializer): - user = NestedUserSerializer() - action = ChoiceField(choices=ACTION_CHOICES) - - class Meta: - model = UserAction - fields = ['id', 'time', 'user', 'action', 'message'] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index cf61841dd..1bdcf181b 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = ExtrasRootView # Field choices -router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') # Graphs router.register(r'graphs', views.GraphViewSet) @@ -38,13 +36,10 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet) router.register(r'config-contexts', views.ConfigContextViewSet) # Reports -router.register(r'reports', views.ReportViewSet, base_name='report') +router.register(r'reports', views.ReportViewSet, basename='report') # Change logging router.register(r'object-changes', views.ObjectChangeViewSet) -# Recent activity -router.register(r'recent-activity', views.RecentActivityViewSet) - app_name = 'extras-api' urlpatterns = router.urls diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 0fefa7ae6..637ef235b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404, HttpResponse @@ -13,7 +11,6 @@ from taggit.models import Tag from extras import filters from extras.models import ( ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, - UserAction, ) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -53,7 +50,7 @@ class CustomFieldModelViewSet(ModelViewSet): custom_field_choices[cfc.id] = cfc.value custom_field_choices = custom_field_choices - context = super(CustomFieldModelViewSet, self).get_serializer_context() + context = super().get_serializer_context() context.update({ 'custom_fields': custom_fields, 'custom_field_choices': custom_field_choices, @@ -62,7 +59,7 @@ class CustomFieldModelViewSet(ModelViewSet): def get_queryset(self): # Prefetch custom field values - return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') + return super().get_queryset().prefetch_related('custom_field_values__field') # @@ -72,7 +69,7 @@ class CustomFieldModelViewSet(ModelViewSet): class GraphViewSet(ModelViewSet): queryset = Graph.objects.all() serializer_class = serializers.GraphSerializer - filter_class = filters.GraphFilter + filterset_class = filters.GraphFilter # @@ -82,7 +79,7 @@ class GraphViewSet(ModelViewSet): class ExportTemplateViewSet(ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer - filter_class = filters.ExportTemplateFilter + filterset_class = filters.ExportTemplateFilter # @@ -92,7 +89,7 @@ class ExportTemplateViewSet(ModelViewSet): class TopologyMapViewSet(ModelViewSet): queryset = TopologyMap.objects.select_related('site') serializer_class = serializers.TopologyMapSerializer - filter_class = filters.TopologyMapFilter + filterset_class = filters.TopologyMapFilter @action(detail=True) def render(self, request, pk): @@ -121,7 +118,7 @@ class TopologyMapViewSet(ModelViewSet): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) serializer_class = serializers.TagSerializer - filter_class = filters.TagFilter + filterset_class = filters.TagFilter # @@ -142,7 +139,7 @@ class ConfigContextViewSet(ModelViewSet): 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer - filter_class = filters.ConfigContextFilter + filterset_class = filters.ConfigContextFilter # @@ -231,17 +228,4 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): """ queryset = ObjectChange.objects.select_related('user') serializer_class = serializers.ObjectChangeSerializer - filter_class = filters.ObjectChangeFilter - - -# -# User activity -# - -class RecentActivityViewSet(ReadOnlyModelViewSet): - """ - DEPRECATED: List all UserActions to provide a log of recent activity. - """ - queryset = UserAction.objects.all() - serializer_class = serializers.UserActionSerializer - filter_class = filters.UserActionFilter + filterset_class = filters.ObjectChangeFilter diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 4520b1923..2d4517c26 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.core.exceptions import ImproperlyConfigured class ExtrasConfig(AppConfig): diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 9707d9121..51fc398f7 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # Models which support custom fields CUSTOMFIELD_MODELS = ( @@ -51,7 +49,7 @@ GRAPH_TYPE_CHOICES = ( EXPORTTEMPLATE_MODELS = [ 'provider', 'circuit', # Circuits 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM - 'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis', # DCIM + 'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM 'secret', # Secrets 'tenant', # Tenancy diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 3abd5b4cf..f3301a6cc 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,7 +1,4 @@ -from __future__ import unicode_literals - import django_filters -from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q from taggit.models import Tag @@ -9,7 +6,7 @@ from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction +from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap class CustomFieldFilter(django_filters.Filter): @@ -20,12 +17,12 @@ class CustomFieldFilter(django_filters.Filter): def __init__(self, custom_field, *args, **kwargs): self.cf_type = custom_field.type self.filter_logic = custom_field.filter_logic - super(CustomFieldFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def filter(self, queryset, value): # Skip filter on empty value - if not value.strip(): + if value is None or not value.strip(): return queryset # Selection fields get special treatment (values must be integers) @@ -66,12 +63,12 @@ class CustomFieldFilterSet(django_filters.FilterSet): """ def __init__(self, *args, **kwargs): - super(CustomFieldFilterSet, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) obj_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED) for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf) + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) class GraphFilter(django_filters.FilterSet): @@ -109,12 +106,12 @@ class TagFilter(django_filters.FilterSet): class TopologyMapFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( - name='site', + field_name='site', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -131,67 +128,67 @@ class ConfigContextFilter(django_filters.FilterSet): label='Search', ) region_id = django_filters.ModelMultipleChoiceFilter( - name='regions', + field_name='regions', queryset=Region.objects.all(), label='Region', ) region = django_filters.ModelMultipleChoiceFilter( - name='regions__slug', + field_name='regions__slug', queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='sites', + field_name='sites', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='sites__slug', + field_name='sites__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='roles', + field_name='roles', queryset=DeviceRole.objects.all(), label='Role', ) role = django_filters.ModelMultipleChoiceFilter( - name='roles__slug', + field_name='roles__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', ) platform_id = django_filters.ModelMultipleChoiceFilter( - name='platforms', + field_name='platforms', queryset=Platform.objects.all(), label='Platform', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platforms__slug', + field_name='platforms__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( - name='tenant_groups', + field_name='tenant_groups', queryset=TenantGroup.objects.all(), label='Tenant group', ) tenant_group = django_filters.ModelMultipleChoiceFilter( - name='tenant_groups__slug', + field_name='tenant_groups__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', label='Tenant group (slug)', ) tenant_id = django_filters.ModelMultipleChoiceFilter( - name='tenants', + field_name='tenants', queryset=Tenant.objects.all(), label='Tenant', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenants__slug', + field_name='tenants__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -229,15 +226,3 @@ class ObjectChangeFilter(django_filters.FilterSet): Q(user_name__icontains=value) | Q(object_repr__icontains=value) ) - - -class UserActionFilter(django_filters.FilterSet): - username = django_filters.ModelMultipleChoiceFilter( - name='user__username', - queryset=User.objects.all(), - to_field_name='username', - ) - - class Meta: - model = UserAction - fields = ['user'] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 6fc4b8859..c0d6732d1 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from django import forms @@ -104,7 +102,7 @@ class CustomFieldForm(forms.ModelForm): self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self._meta.model) - super(CustomFieldForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form custom_fields = [] @@ -140,7 +138,7 @@ class CustomFieldForm(forms.ModelForm): cfv.save() def save(self, commit=True): - obj = super(CustomFieldForm, self).save(commit) + obj = super().save(commit) # Handle custom fields the same way we do M2M fields if commit: @@ -154,7 +152,7 @@ class CustomFieldForm(forms.ModelForm): class CustomFieldBulkEditForm(BulkEditForm): def __init__(self, *args, **kwargs): - super(CustomFieldBulkEditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.custom_fields = [] self.obj_type = ContentType.objects.get_for_model(self.model) @@ -177,7 +175,7 @@ class CustomFieldFilterForm(forms.Form): self.obj_type = ContentType.objects.get_for_model(self.model) - super(CustomFieldFilterForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() @@ -195,13 +193,15 @@ class TagForm(BootstrapMixin, forms.ModelForm): class Meta: model = Tag - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class AddRemoveTagsForm(forms.Form): def __init__(self, *args, **kwargs): - super(AddRemoveTagsForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Add add/remove tags fields self.fields['add_tags'] = TagField(required=False) @@ -210,7 +210,10 @@ class AddRemoveTagsForm(forms.Form): class TagFilterForm(BootstrapMixin, forms.Form): model = Tag - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) # @@ -251,7 +254,9 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['description'] + nullable_fields = [ + 'description', + ] class ConfigContextFilterForm(BootstrapMixin, forms.Form): @@ -293,7 +298,9 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): class Meta: model = ImageAttachment - fields = ['name', 'image'] + fields = [ + 'name', 'image', + ] # diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 15b8acac5..c5a2fa1ec 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import code import platform import sys diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py deleted file mode 100644 index c42bdf50a..000000000 --- a/netbox/extras/management/commands/run_inventory.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import unicode_literals - -from getpass import getpass - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError -from django.db import transaction -from ncclient.transport.errors import AuthenticationError -from paramiko import AuthenticationException - -from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site - - -class Command(BaseCommand): - help = "Update inventory information for specified devices" - username = settings.NAPALM_USERNAME - password = settings.NAPALM_PASSWORD - - def add_arguments(self, parser): - parser.add_argument('-u', '--username', dest='username', help="Specify the username to use") - parser.add_argument('-p', '--password', action='store_true', default=False, help="Prompt for password to use") - parser.add_argument('-s', '--site', dest='site', action='append', - help="Filter devices by site (include argument once per site)") - parser.add_argument('-n', '--name', dest='name', help="Filter devices by name (regular expression)") - parser.add_argument('--full', action='store_true', default=False, help="For inventory update for all devices") - parser.add_argument('--fake', action='store_true', default=False, help="Do not actually update database") - - def handle(self, *args, **options): - - def create_inventory_items(inventory_items, parent=None): - for item in inventory_items: - i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'], - serial=item['serial'], discovered=True) - i.save() - create_inventory_items(item.get('items', []), parent=i) - - # Credentials - if options['username']: - self.username = options['username'] - if options['password']: - self.password = getpass("Password: ") - - # Attempt to inventory only active devices - device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE) - - # --site: Include only devices belonging to specified site(s) - if options['site']: - sites = Site.objects.filter(slug__in=options['site']) - if sites: - site_names = [s.name for s in sites] - self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names))) - else: - raise CommandError("One or more sites specified but none found.") - device_list = device_list.filter(site__in=sites) - - # --name: Filter devices by name matching a regex - if options['name']: - device_list = device_list.filter(name__iregex=options['name']) - - # --full: Gather inventory data for *all* devices - if options['full']: - self.stdout.write("WARNING: Running inventory for all devices! Prior data will be overwritten. (--full)") - - # --fake: Gathering data but not updating the database - if options['fake']: - self.stdout.write("WARNING: Inventory data will not be saved! (--fake)") - - device_count = device_list.count() - self.stdout.write("** Found {} devices...".format(device_count)) - - for i, device in enumerate(device_list, start=1): - - self.stdout.write("[{}/{}] {}: ".format(i, device_count, device.name), ending='') - - # Skip inactive devices - if not device.status: - self.stdout.write("Skipped (not active)") - continue - - # Skip devices without primary_ip set - if not device.primary_ip: - self.stdout.write("Skipped (no primary IP set)") - continue - - # Skip devices which have already been inventoried if not doing a full update - if device.serial and not options['full']: - self.stdout.write("Skipped (Serial: {})".format(device.serial)) - continue - - RPC = device.get_rpc_client() - if not RPC: - self.stdout.write("Skipped (no RPC client available for platform {})".format(device.platform)) - continue - - # Connect to device and retrieve inventory info - try: - with RPC(device, self.username, self.password) as rpc_client: - inventory = rpc_client.get_inventory() - except KeyboardInterrupt: - raise - except (AuthenticationError, AuthenticationException): - self.stdout.write("Authentication error!") - continue - except Exception as e: - self.stdout.write("Error: {}".format(e)) - continue - - if options['verbosity'] > 1: - self.stdout.write("") - self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial'])) - self.stdout.write("\tDescription: {}".format(inventory['chassis']['description'])) - for item in inventory['items']: - self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'], - item['serial'])) - else: - self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial'])) - - if not options['fake']: - with transaction.atomic(): - # Update device serial - if device.serial != inventory['chassis']['serial']: - device.serial = inventory['chassis']['serial'] - device.save() - InventoryItem.objects.filter(device=device, discovered=True).delete() - create_inventory_items(inventory.get('items', [])) - - self.stdout.write("Finished!") diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index 96efc43a0..efc789021 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.core.management.base import BaseCommand from django.utils import timezone diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 7dfddbad6..16461c32a 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals - -from datetime import timedelta import random import threading import uuid +from datetime import timedelta from django.conf import settings from django.db.models.signals import post_delete, post_save @@ -16,7 +14,6 @@ from .constants import ( ) from .models import ObjectChange - _thread_locals = threading.local() diff --git a/netbox/extras/migrations/0001_initial.py b/netbox/extras/migrations/0001_initial.py index 949b3a2d8..be9b95264 100644 --- a/netbox/extras/migrations/0001_initial.py +++ b/netbox/extras/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py index 1021c20c5..c6167ff9f 100644 --- a/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0001_initial_squashed_0010_customfield_filter_logic.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:19 -from __future__ import unicode_literals from django.conf import settings import django.contrib.postgres.fields.jsonb diff --git a/netbox/extras/migrations/0002_custom_fields.py b/netbox/extras/migrations/0002_custom_fields.py index 1d33ca281..300ae758a 100644 --- a/netbox/extras/migrations/0002_custom_fields.py +++ b/netbox/extras/migrations/0002_custom_fields.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-08-23 20:33 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/extras/migrations/0003_exporttemplate_add_description.py b/netbox/extras/migrations/0003_exporttemplate_add_description.py index 6355955b5..fc45f5255 100644 --- a/netbox/extras/migrations/0003_exporttemplate_add_description.py +++ b/netbox/extras/migrations/0003_exporttemplate_add_description.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-27 20:20 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py index ee838046d..b35c641da 100644 --- a/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py +++ b/netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-11-03 18:33 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0005_useraction_add_bulk_create.py b/netbox/extras/migrations/0005_useraction_add_bulk_create.py index 0f20e5214..58b66fe1a 100644 --- a/netbox/extras/migrations/0005_useraction_add_bulk_create.py +++ b/netbox/extras/migrations/0005_useraction_add_bulk_create.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-04 19:45 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0006_add_imageattachments.py b/netbox/extras/migrations/0006_add_imageattachments.py index c4c589a9e..6842cced0 100644 --- a/netbox/extras/migrations/0006_add_imageattachments.py +++ b/netbox/extras/migrations/0006_add_imageattachments.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-04 19:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import extras.models diff --git a/netbox/extras/migrations/0007_unicode_literals.py b/netbox/extras/migrations/0007_unicode_literals.py index cda07583f..fecb33b7b 100644 --- a/netbox/extras/migrations/0007_unicode_literals.py +++ b/netbox/extras/migrations/0007_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models import extras.models diff --git a/netbox/extras/migrations/0008_reports.py b/netbox/extras/migrations/0008_reports.py index 9c26f50ba..e0c747532 100644 --- a/netbox/extras/migrations/0008_reports.py +++ b/netbox/extras/migrations/0008_reports.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-26 21:25 -from __future__ import unicode_literals from django.conf import settings import django.contrib.postgres.fields.jsonb diff --git a/netbox/extras/migrations/0009_topologymap_type.py b/netbox/extras/migrations/0009_topologymap_type.py index b062c58af..bc9ec07d5 100644 --- a/netbox/extras/migrations/0009_topologymap_type.py +++ b/netbox/extras/migrations/0009_topologymap_type.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-15 16:28 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py index e35a2f835..dbff03e2d 100644 --- a/netbox/extras/migrations/0010_customfield_filter_logic.py +++ b/netbox/extras/migrations/0010_customfield_filter_logic.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-21 19:48 -from __future__ import unicode_literals - from django.db import migrations, models from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT diff --git a/netbox/extras/migrations/0012_webhooks.py b/netbox/extras/migrations/0012_webhooks.py index 70c8e9c14..8f7fcf36f 100644 --- a/netbox/extras/migrations/0012_webhooks.py +++ b/netbox/extras/migrations/0012_webhooks.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-30 17:55 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/extras/migrations/0013_objectchange.py b/netbox/extras/migrations/0013_objectchange.py index de4762a46..01d73a841 100644 --- a/netbox/extras/migrations/0013_objectchange.py +++ b/netbox/extras/migrations/0013_objectchange.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-22 18:13 -from __future__ import unicode_literals - from django.conf import settings import django.contrib.postgres.fields.jsonb from django.db import migrations, models diff --git a/netbox/extras/migrations/0015_remove_useraction.py b/netbox/extras/migrations/0015_remove_useraction.py new file mode 100644 index 000000000..eb750bc36 --- /dev/null +++ b/netbox/extras/migrations/0015_remove_useraction.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.8 on 2018-08-14 16:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0014_configcontexts'), + ] + + operations = [ + migrations.RemoveField( + model_name='useraction', + name='content_type', + ), + migrations.RemoveField( + model_name='useraction', + name='user', + ), + migrations.DeleteModel( + name='UserAction', + ), + ] diff --git a/netbox/extras/migrations/0016_exporttemplate_add_cable.py b/netbox/extras/migrations/0016_exporttemplate_add_cable.py new file mode 100644 index 000000000..3b8852f44 --- /dev/null +++ b/netbox/extras/migrations/0016_exporttemplate_add_cable.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.3 on 2018-11-07 20:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0015_remove_useraction'), + ] + + operations = [ + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ['provider', 'circuit', 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', 'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'secret', 'tenant', 'cluster', 'virtualmachine']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1605df6df..d3b9f4eff 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from datetime import date @@ -10,12 +8,10 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField from django.core.validators import ValidationError from django.db import models -from django.db.models import Q +from django.db.models import F, Q from django.http import HttpResponse from django.template import Template, Context from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible -from django.utils.safestring import mark_safe from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.utils import deepmerge, foreground_color @@ -27,7 +23,6 @@ from .querysets import ConfigContextQuerySet # Webhooks # -@python_2_unicode_compatible class Webhook(models.Model): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or @@ -136,7 +131,6 @@ class CustomFieldModel(models.Model): return OrderedDict([(field, None) for field in fields]) -@python_2_unicode_compatible class CustomField(models.Model): obj_type = models.ManyToManyField( to=ContentType, @@ -227,7 +221,6 @@ class CustomField(models.Model): return serialized_value -@python_2_unicode_compatible class CustomFieldValue(models.Model): field = models.ForeignKey( to='extras.CustomField', @@ -268,10 +261,9 @@ class CustomFieldValue(models.Model): if self.pk and self.value is None: self.delete() else: - super(CustomFieldValue, self).save(*args, **kwargs) + super().save(*args, **kwargs) -@python_2_unicode_compatible class CustomFieldChoice(models.Model): field = models.ForeignKey( to='extras.CustomField', @@ -301,7 +293,7 @@ class CustomFieldChoice(models.Model): def delete(self, using=None, keep_parents=False): # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it pk = self.pk - super(CustomFieldChoice, self).delete(using, keep_parents) + super().delete(using, keep_parents) CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() @@ -309,7 +301,6 @@ class CustomFieldChoice(models.Model): # Graphs # -@python_2_unicode_compatible class Graph(models.Model): type = models.PositiveSmallIntegerField( choices=GRAPH_TYPE_CHOICES @@ -351,7 +342,6 @@ class Graph(models.Model): # Export templates # -@python_2_unicode_compatible class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, @@ -410,7 +400,6 @@ class ExportTemplate(models.Model): # Topology maps # -@python_2_unicode_compatible class TopologyMap(models.Model): name = models.CharField( max_length=50, @@ -515,18 +504,22 @@ class TopologyMap(models.Model): def add_network_connections(self, devices): from circuits.models import CircuitTermination - from dcim.models import InterfaceConnection + from dcim.models import Interface # Add all interface connections to the graph - connections = InterfaceConnection.objects.filter( - interface_a__device__in=devices, interface_b__device__in=devices + connected_interfaces = Interface.objects.select_related( + '_connected_interface__device' + ).filter( + Q(device__in=devices) | Q(_connected_interface__device__in=devices), + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') ) - for c in connections: - style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) + for interface in connected_interfaces: + style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' + self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style) # Add all circuits to the graph - for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): + for termination in CircuitTermination.objects.filter(term_side='A', connected_endpoint__device__in=devices): peer_termination = termination.get_peer_termination() if (peer_termination is not None and peer_termination.interface is not None and peer_termination.interface.device in devices): @@ -537,20 +530,18 @@ class TopologyMap(models.Model): from dcim.models import ConsolePort # Add all console connections to the graph - console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices) - for cp in console_ports: + for cp in ConsolePort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style) + self.graph.edge(cp.connected_endpoint.device.name, cp.device.name, style=style) def add_power_connections(self, devices): from dcim.models import PowerPort # Add all power connections to the graph - power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices) - for pp in power_ports: + for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' - self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style) + self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) # @@ -571,7 +562,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@python_2_unicode_compatible class ImageAttachment(models.Model): """ An uploaded image which is associated with an object. @@ -613,7 +603,7 @@ class ImageAttachment(models.Model): _name = self.image.name - super(ImageAttachment, self).delete(*args, **kwargs) + super().delete(*args, **kwargs) # Delete file from disk self.image.delete(save=False) @@ -769,7 +759,6 @@ class ReportResult(models.Model): # Change logging # -@python_2_unicode_compatible class ObjectChange(models.Model): """ Record a change to an object and the user account associated with that change. A change record may optionally @@ -852,7 +841,7 @@ class ObjectChange(models.Model): self.user_name = self.user.username self.object_repr = str(self.changed_object) - return super(ObjectChange, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def get_absolute_url(self): return reverse('extras:objectchange', args=[self.pk]) @@ -871,101 +860,3 @@ class ObjectChange(models.Model): self.object_repr, self.object_data, ) - - -# -# User actions -# - -class UserActionManager(models.Manager): - - # Actions affecting a single object - def log_action(self, user, obj, action, message): - self.model.objects.create( - content_type=ContentType.objects.get_for_model(obj), - object_id=obj.pk, - user=user, - action=action, - message=message, - ) - - def log_create(self, user, obj, message=''): - self.log_action(user, obj, ACTION_CREATE, message) - - def log_edit(self, user, obj, message=''): - self.log_action(user, obj, ACTION_EDIT, message) - - def log_delete(self, user, obj, message=''): - self.log_action(user, obj, ACTION_DELETE, message) - - # Actions affecting multiple objects - def log_bulk_action(self, user, content_type, action, message): - self.model.objects.create( - content_type=content_type, - user=user, - action=action, - message=message, - ) - - def log_import(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_IMPORT, message) - - def log_bulk_create(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message) - - def log_bulk_edit(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message) - - def log_bulk_delete(self, user, content_type, message=''): - self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message) - - -# TODO: Remove UserAction, which has been replaced by ObjectChange. -@python_2_unicode_compatible -class UserAction(models.Model): - """ - DEPRECATED: A record of an action (add, edit, or delete) performed on an object by a User. - """ - time = models.DateTimeField( - auto_now_add=True, - editable=False - ) - user = models.ForeignKey( - to=User, - on_delete=models.CASCADE, - related_name='actions' - ) - content_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE - ) - object_id = models.PositiveIntegerField( - blank=True, - null=True - ) - action = models.PositiveSmallIntegerField( - choices=ACTION_CHOICES - ) - message = models.TextField( - blank=True - ) - - objects = UserActionManager() - - class Meta: - ordering = ['-time'] - - def __str__(self): - if self.message: - return '{} {}'.format(self.user, self.message) - return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type) - - def icon(self): - if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]: - return mark_safe('') - elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]: - return mark_safe('') - elif self.action in [ACTION_DELETE, ACTION_BULK_DELETE]: - return mark_safe('') - else: - return '' diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index bcc6f1e54..439323c94 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import Q, QuerySet diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 52883063c..fc41b45f9 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - -from collections import OrderedDict import importlib import inspect import pkgutil -import sys +from collections import OrderedDict from django.conf import settings from django.utils import timezone @@ -26,22 +23,12 @@ def get_report(module_name, report_name): """ file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name) - # Python 3.5+ - if sys.version_info >= (3, 5): - spec = importlib.util.spec_from_file_location(module_name, file_path) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except FileNotFoundError: - return None - - # Python 2.7 - else: - import imp - try: - module = imp.load_source(module_name, file_path) - except IOError: - return None + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + except FileNotFoundError: + return None report = getattr(module, report_name, None) if report is None: diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py deleted file mode 100644 index 552f592c7..000000000 --- a/netbox/extras/rpc.py +++ /dev/null @@ -1,237 +0,0 @@ -from __future__ import unicode_literals - -import re -import time - -import paramiko -import xmltodict -from ncclient import manager - -CONNECT_TIMEOUT = 5 # seconds - - -class RPCClient(object): - - def __init__(self, device, username='', password=''): - self.username = username - self.password = password - try: - self.host = str(device.primary_ip.address.ip) - except AttributeError: - raise Exception("Specified device ({}) does not have a primary IP defined.".format(device)) - - def get_inventory(self): - """ - Returns a dictionary representing the device chassis and installed inventory items. - - { - 'chassis': { - 'serial': , - 'description': , - } - 'items': [ - { - 'name': , - 'part_id': , - 'serial': , - }, - ... - ] - } - """ - raise NotImplementedError("Feature not implemented for this platform.") - - -class SSHClient(RPCClient): - def __enter__(self): - - self.ssh = paramiko.SSHClient() - self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - self.ssh.connect( - self.host, - username=self.username, - password=self.password, - timeout=CONNECT_TIMEOUT, - allow_agent=False, - look_for_keys=False, - ) - except paramiko.AuthenticationException: - # Try default credentials if the configured creds don't work - try: - default_creds = self.default_credentials - if default_creds.get('username') and default_creds.get('password'): - self.ssh.connect( - self.host, - username=default_creds['username'], - password=default_creds['password'], - timeout=CONNECT_TIMEOUT, - allow_agent=False, - look_for_keys=False, - ) - else: - raise ValueError('default_credentials are incomplete.') - except AttributeError: - raise paramiko.AuthenticationException - - self.session = self.ssh.invoke_shell() - self.session.recv(1000) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.ssh.close() - - def _send(self, cmd, pause=1): - self.session.send('{}\n'.format(cmd)) - data = '' - time.sleep(pause) - while self.session.recv_ready(): - data += self.session.recv(4096).decode() - if not data: - break - return data - - -class JunosNC(RPCClient): - """ - NETCONF client for Juniper Junos devices - """ - - def __enter__(self): - - # Initiate a connection to the device - self.manager = manager.connect(host=self.host, username=self.username, password=self.password, - hostkey_verify=False, timeout=CONNECT_TIMEOUT) - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - - # Close the connection to the device - self.manager.close_session() - - def get_inventory(self): - - def glean_items(node, depth=0): - items = [] - items_list = node.get('chassis{}-module'.format('-sub' * depth), []) - # Junos like to return single children directly instead of as a single-item list - if hasattr(items_list, 'items'): - items_list = [items_list] - for item in items_list: - m = { - 'name': item['name'], - 'part_id': item.get('model-number') or item.get('part-number', ''), - 'serial': item.get('serial-number', ''), - } - child_items = glean_items(item, depth + 1) - if child_items: - m['items'] = child_items - items.append(m) - return items - - rpc_reply = self.manager.dispatch('get-chassis-inventory') - inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis'] - - result = dict() - - # Gather chassis data - result['chassis'] = { - 'serial': inventory_raw['serial-number'], - 'description': inventory_raw['description'], - } - - # Gather inventory items - result['items'] = glean_items(inventory_raw) - - return result - - -class IOSSSH(SSHClient): - """ - SSH client for Cisco IOS devices - """ - - def get_inventory(self): - def version(): - - def parse(cmd_out, rex): - for i in cmd_out: - match = re.search(rex, i) - if match: - return match.groups()[0] - - sh_ver = self._send('show version').split('\r\n') - return { - 'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'), - 'description': parse(sh_ver, r'cisco ([^\s]+)') - } - - def items(chassis_serial=None): - cmd = self._send('show inventory').split('\r\n\r\n') - for i in cmd: - i_fmt = i.replace('\r\n', ' ') - try: - m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1) - m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1) - m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1) - # Omit built-in items and those with no PID - if m_serial != chassis_serial and m_pid.lower() != 'unspecified': - yield { - 'name': m_name, - 'part_id': m_pid, - 'serial': m_serial, - } - except AttributeError: - continue - - self._send('term length 0') - sh_version = version() - - return { - 'chassis': sh_version, - 'items': list(items(chassis_serial=sh_version.get('serial'))) - } - - -class OpengearSSH(SSHClient): - """ - SSH client for Opengear devices - """ - default_credentials = { - 'username': 'root', - 'password': 'default', - } - - def get_inventory(self): - - try: - stdin, stdout, stderr = self.ssh.exec_command("showserial") - serial = stdout.readlines()[0].strip() - except Exception: - raise RuntimeError("Failed to glean chassis serial from device.") - # Older models don't provide serial info - if serial == "No serial number information available": - serial = '' - - try: - stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model") - description = stdout.readlines()[0].split(' ', 1)[1].strip() - except Exception: - raise RuntimeError("Failed to glean chassis description from device.") - - return { - 'chassis': { - 'serial': serial, - 'description': description, - }, - 'items': [], - } - - -# For mapping platform -> NC client -RPC_CLIENTS = { - 'juniper-junos': JunosNC, - 'cisco-ios': IOSSSH, - 'opengear': OpengearSSH, -} diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index cf2b6f888..5fab8910f 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor from taggit.models import Tag, TaggedItem diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 3d0e5d1f7..cccb00a8a 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status @@ -16,7 +14,7 @@ class GraphTest(APITestCase): def setUp(self): - super(GraphTest, self).setUp() + super().setUp() self.graph1 = Graph.objects.create( type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1' @@ -120,7 +118,7 @@ class ExportTemplateTest(APITestCase): def setUp(self): - super(ExportTemplateTest, self).setUp() + super().setUp() self.content_type = ContentType.objects.get_for_model(Device) self.exporttemplate1 = ExportTemplate.objects.create( @@ -227,7 +225,7 @@ class TagTest(APITestCase): def setUp(self): - super(TagTest, self).setUp() + super().setUp() self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1') self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2') @@ -318,7 +316,7 @@ class ConfigContextTest(APITestCase): def setUp(self): - super(ConfigContextTest, self).setUp() + super().setUp() self.configcontext1 = ConfigContext.objects.create( name='Test Config Context 1', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 97eb69cd9..b02e787c1 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from datetime import date from django.contrib.contenttypes.models import ContentType @@ -103,7 +101,7 @@ class CustomFieldAPITest(APITestCase): def setUp(self): - super(CustomFieldAPITest, self).setUp() + super().setUp() content_type = ContentType.objects.get_for_model(Site) diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index d4c0a79c6..4f509a5e9 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -14,7 +12,7 @@ class TaggedItemTest(APITestCase): def setUp(self): - super(TaggedItemTest, self).setUp() + super().setUp() def test_create_tagged_item(self): diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index a97019a04..12a2fbf6b 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras import views diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3e9186490..e7087e511 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template from django.conf import settings from django.contrib import messages @@ -58,7 +56,7 @@ class TagView(View): # Generate a table of all items tagged with this Tag items_table = TaggedItemTable(tagged_items) paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(items_table) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 35ec56feb..12dc7558b 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -3,8 +3,8 @@ import datetime from django.conf import settings from django.contrib.contenttypes.models import ContentType -from extras.models import Webhook from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE +from extras.models import Webhook from utilities.api import get_serializer_for_model from .constants import WEBHOOK_MODELS diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 30f86f311..5a680f5d1 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,8 +1,8 @@ import hashlib import hmac -import requests import json +import requests from django_rq import job from rest_framework.utils.encoders import JSONEncoder diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py new file mode 100644 index 000000000..2ffaa0ae2 --- /dev/null +++ b/netbox/ipam/api/nested_serializers.py @@ -0,0 +1,100 @@ +from rest_framework import serializers + +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedAggregateSerializer', + 'NestedIPAddressSerializer', + 'NestedPrefixSerializer', + 'NestedRIRSerializer', + 'NestedRoleSerializer', + 'NestedVLANGroupSerializer', + 'NestedVLANSerializer', + 'NestedVRFSerializer', +] + + +# +# VRFs +# + +class NestedVRFSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + + class Meta: + model = VRF + fields = ['id', 'url', 'name', 'rd'] + + +# +# RIRs/aggregates +# + +class NestedRIRSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') + + class Meta: + model = RIR + fields = ['id', 'url', 'name', 'slug'] + + +class NestedAggregateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') + + class Meta: + model = Aggregate + fields = ['id', 'url', 'family', 'prefix'] + + +# +# VLANs +# + +class NestedRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') + + class Meta: + model = Role + fields = ['id', 'url', 'name', 'slug'] + + +class NestedVLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedVLANSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') + + class Meta: + model = VLAN + fields = ['id', 'url', 'vid', 'name', 'display_name'] + + +# +# Prefixes +# + +class NestedPrefixSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') + + class Meta: + model = Prefix + fields = ['id', 'url', 'family', 'prefix'] + + +# +# IP addresses +# + + +class NestedIPAddressSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') + + class Meta: + model = IPAddress + fields = ['id', 'url', 'family', 'address'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4ba62e8da..030266188 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from collections import OrderedDict from rest_framework import serializers @@ -7,18 +5,17 @@ from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer -from ipam.constants import ( - IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES, -) +from ipam.constants import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from tenancy.api.serializers import NestedTenantSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import ( ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, ) -from virtualization.api.serializers import NestedVirtualMachineSerializer +from virtualization.api.nested_serializers import NestedVirtualMachineSerializer +from .nested_serializers import * # @@ -37,35 +34,8 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): ] -class NestedVRFSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') - - class Meta: - model = VRF - fields = ['id', 'url', 'name', 'rd'] - - # -# Roles -# - -class RoleSerializer(ValidatedModelSerializer): - - class Meta: - model = Role - fields = ['id', 'name', 'slug', 'weight'] - - -class NestedRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') - - class Meta: - model = Role - fields = ['id', 'url', 'name', 'slug'] - - -# -# RIRs +# RIRs/aggregates # class RIRSerializer(ValidatedModelSerializer): @@ -75,18 +45,6 @@ class RIRSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'is_private'] -class NestedRIRSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') - - class Meta: - model = RIR - fields = ['id', 'url', 'name', 'slug'] - - -# -# Aggregates -# - class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): rir = NestedRIRSerializer() tags = TagListSerializerField(required=False) @@ -100,18 +58,17 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): read_only_fields = ['family'] -class NestedAggregateSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') - - class Meta(AggregateSerializer.Meta): - model = Aggregate - fields = ['id', 'url', 'family', 'prefix'] - - # -# VLAN groups +# VLANs # +class RoleSerializer(ValidatedModelSerializer): + + class Meta: + model = Role + fields = ['id', 'name', 'slug', 'weight'] + + class VLANGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) @@ -130,23 +87,11 @@ class VLANGroupSerializer(ValidatedModelSerializer): validator(data) # Enforce model validation - super(VLANGroupSerializer, self).validate(data) + super().validate(data) return data -class NestedVLANGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - - class Meta: - model = VLANGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# VLANs -# - class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) @@ -173,19 +118,11 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): validator(data) # Enforce model validation - super(VLANSerializer, self).validate(data) + super().validate(data) return data -class NestedVLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - # # Prefixes # @@ -208,16 +145,10 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): read_only_fields = ['family'] -class NestedPrefixSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') - - class Meta: - model = Prefix - fields = ['id', 'url', 'family', 'prefix'] - - class AvailablePrefixSerializer(serializers.Serializer): - + """ + Representation of a prefix which does not exist in the database. + """ def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data @@ -235,11 +166,14 @@ class AvailablePrefixSerializer(serializers.Serializer): # class IPAddressInterfaceSerializer(WritableNestedSerializer): + """ + Nested representation of an Interface which may belong to a Device *or* a VirtualMachine. + """ url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here device = NestedDeviceSerializer(read_only=True) virtual_machine = NestedVirtualMachineSerializer(read_only=True) - class Meta(InterfaceSerializer.Meta): + class Meta: model = Interface fields = [ 'id', 'url', 'device', 'virtual_machine', 'name', @@ -260,6 +194,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) + nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) + nat_outside = NestedIPAddressSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: @@ -271,20 +207,10 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): read_only_fields = ['family'] -class NestedIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - -IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer(required=False, allow_null=True) -IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer(read_only=True) - - class AvailableIPSerializer(serializers.Serializer): - + """ + Representation of an IP address which does not exist in the database. + """ def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index ca046cd93..9a2e1bc1f 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = IPAMRootView # Field choices -router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice') # VRFs router.register(r'vrfs', views.VRFViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 41cea7eaa..e846f0489 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from django.shortcuts import get_object_or_404 from rest_framework import status @@ -35,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.select_related('tenant').prefetch_related('tags') serializer_class = serializers.VRFSerializer - filter_class = filters.VRFFilter + filterset_class = filters.VRFFilter # @@ -45,7 +43,7 @@ class VRFViewSet(CustomFieldModelViewSet): class RIRViewSet(ModelViewSet): queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer - filter_class = filters.RIRFilter + filterset_class = filters.RIRFilter # @@ -55,7 +53,7 @@ class RIRViewSet(ModelViewSet): class AggregateViewSet(CustomFieldModelViewSet): queryset = Aggregate.objects.select_related('rir').prefetch_related('tags') serializer_class = serializers.AggregateSerializer - filter_class = filters.AggregateFilter + filterset_class = filters.AggregateFilter # @@ -65,7 +63,7 @@ class AggregateViewSet(CustomFieldModelViewSet): class RoleViewSet(ModelViewSet): queryset = Role.objects.all() serializer_class = serializers.RoleSerializer - filter_class = filters.RoleFilter + filterset_class = filters.RoleFilter # @@ -75,7 +73,7 @@ class RoleViewSet(ModelViewSet): class PrefixViewSet(CustomFieldModelViewSet): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags') serializer_class = serializers.PrefixSerializer - filter_class = filters.PrefixFilter + filterset_class = filters.PrefixFilter @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) def available_prefixes(self, request, pk=None): @@ -98,25 +96,34 @@ class PrefixViewSet(CustomFieldModelViewSet): for i, requested_prefix in enumerate(requested_prefixes): # Validate requested prefix size - error_msg = None - if 'prefix_length' not in requested_prefix: - error_msg = "Item {}: prefix_length field missing".format(i) - elif not isinstance(requested_prefix['prefix_length'], int): - error_msg = "Item {}: Invalid prefix length ({})".format( - i, requested_prefix['prefix_length'] - ) - elif prefix.family == 4 and requested_prefix['prefix_length'] > 32: - error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format( - i, requested_prefix['prefix_length'] - ) - elif prefix.family == 6 and requested_prefix['prefix_length'] > 128: - error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format( - i, requested_prefix['prefix_length'] - ) - if error_msg: + prefix_length = requested_prefix.get('prefix_length') + if prefix_length is None: return Response( { - "detail": error_msg + "detail": "Item {}: prefix_length field missing".format(i) + }, + status=status.HTTP_400_BAD_REQUEST + ) + try: + prefix_length = int(prefix_length) + except ValueError: + return Response( + { + "detail": "Item {}: Invalid prefix length ({})".format(i, prefix_length), + }, + status=status.HTTP_400_BAD_REQUEST + ) + if prefix.family == 4 and prefix_length > 32: + return Response( + { + "detail": "Item {}: Invalid prefix length ({}) for IPv4".format(i, prefix_length), + }, + status=status.HTTP_400_BAD_REQUEST + ) + elif prefix.family == 6 and prefix_length > 128: + return Response( + { + "detail": "Item {}: Invalid prefix length ({}) for IPv6".format(i, prefix_length), }, status=status.HTTP_400_BAD_REQUEST ) @@ -133,7 +140,7 @@ class PrefixViewSet(CustomFieldModelViewSet): { "detail": "Insufficient space is available to accommodate the requested prefix size(s)" }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_204_NO_CONTENT ) # Remove the allocated prefix from the list of available prefixes @@ -189,7 +196,7 @@ class PrefixViewSet(CustomFieldModelViewSet): "detail": "An insufficient number of IP addresses are available within the prefix {} ({} " "requested, {} available)".format(prefix, len(requested_ips), len(available_ips)) }, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_204_NO_CONTENT ) # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix @@ -248,7 +255,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): 'nat_outside', 'tags', ) serializer_class = serializers.IPAddressSerializer - filter_class = filters.IPAddressFilter + filterset_class = filters.IPAddressFilter # @@ -258,7 +265,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.select_related('site') serializer_class = serializers.VLANGroupSerializer - filter_class = filters.VLANGroupFilter + filterset_class = filters.VLANGroupFilter # @@ -268,7 +275,7 @@ class VLANGroupViewSet(ModelViewSet): class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags') serializer_class = serializers.VLANSerializer - filter_class = filters.VLANFilter + filterset_class = filters.VLANFilter # @@ -278,4 +285,4 @@ class VLANViewSet(CustomFieldModelViewSet): class ServiceViewSet(ModelViewSet): queryset = Service.objects.select_related('device').prefetch_related('tags') serializer_class = serializers.ServiceSerializer - filter_class = filters.ServiceFilter + filterset_class = filters.ServiceFilter diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index c944d1b2c..fd4af74b0 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index a675d3ca9..eeb17eddd 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - # IP address families AF_CHOICES = ( diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 8c7dbb690..1ddf545ea 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - from django.core.exceptions import ValidationError from django.db import models from netaddr import AddrFormatError, IPNetwork -from .formfields import IPFormField from . import lookups +from .formfields import IPFormField def prefix_validator(prefix): @@ -18,7 +16,7 @@ class BaseIPField(models.Field): def python_type(self): return IPNetwork - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection): return self.to_python(value) def to_python(self, value): @@ -42,7 +40,7 @@ class BaseIPField(models.Field): def formfield(self, **kwargs): defaults = {'form_class': self.form_class()} defaults.update(kwargs) - return super(BaseIPField, self).formfield(**defaults) + return super().formfield(**defaults) class IPNetworkField(BaseIPField): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 700a25ae9..abef95c45 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals - import django_filters +import netaddr from django.core.exceptions import ValidationError from django.db.models import Q -import netaddr from netaddr.core import AddrFormatError from dcim.models import Site, Device, Interface @@ -16,7 +14,10 @@ from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLAN class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -26,7 +27,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -48,7 +49,10 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class RIRFilter(django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) class Meta: model = RIR @@ -56,7 +60,10 @@ class RIRFilter(django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -66,7 +73,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): label='RIR (ID)', ) rir = django_filters.ModelMultipleChoiceFilter( - name='rir__slug', + field_name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', label='RIR (slug)', @@ -97,7 +104,10 @@ class RoleFilter(django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -123,7 +133,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( - name='vrf__rd', + field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', @@ -133,7 +143,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -143,7 +153,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -153,7 +163,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VLAN (ID)', ) vlan_vid = django_filters.NumberFilter( - name='vlan__vid', + field_name='vlan__vid', label='VLAN number (1-4095)', ) role_id = django_filters.ModelMultipleChoiceFilter( @@ -161,7 +171,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', label='Role (slug)', @@ -228,7 +238,10 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -250,7 +263,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF', ) vrf = django_filters.ModelMultipleChoiceFilter( - name='vrf__rd', + field_name='vrf__rd', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', @@ -260,28 +273,28 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) device = django_filters.CharFilter( method='filter_device', - name='name', + field_name='name', label='Device', ) device_id = django_filters.NumberFilter( method='filter_device', - name='pk', + field_name='pk', label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - name='interface__virtual_machine', + field_name='interface__virtual_machine', queryset=VirtualMachine.objects.all(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='interface__virtual_machine__name', + field_name='interface__virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine (name)', @@ -353,7 +366,7 @@ class VLANGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -365,7 +378,10 @@ class VLANGroupFilter(django_filters.FilterSet): class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -375,7 +391,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -385,7 +401,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=VLANGroup.objects.all(), to_field_name='slug', label='Group', @@ -395,7 +411,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -405,7 +421,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=Role.objects.all(), to_field_name='slug', label='Role (slug)', @@ -441,7 +457,7 @@ class ServiceFilter(django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='device__name', + field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', @@ -451,7 +467,7 @@ class ServiceFilter(django_filters.FilterSet): label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine__name', + field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine (name)', diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index c67c13414..2909a54b1 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.core.exceptions import ValidationError from netaddr import IPNetwork, AddrFormatError diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8209b2ffa..570716532 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.core.exceptions import MultipleObjectsReturned from django.core.validators import MaxValueValidator, MinValueValidator @@ -36,11 +34,15 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)] # class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = VRF - fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags'] + fields = [ + 'name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant', 'tags', + ] labels = { 'rd': "RD", } @@ -69,22 +71,40 @@ class VRFCSVForm(forms.ModelForm): class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - enforce_unique = forms.NullBooleanField( - required=False, widget=BulkEditNullBooleanSelect, label='Enforce unique space' + pk = forms.ModelMultipleChoiceField( + queryset=VRF.objects.all(), + widget=forms.MultipleHiddenInput() + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + enforce_unique = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Enforce unique space' + ) + description = forms.CharField( + max_length=100, + required=False ) - description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['tenant', 'description'] + nullable_fields = [ + 'tenant', 'description', + ] class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), + queryset=Tenant.objects.annotate( + filter_count=Count('vrfs') + ), to_field_name='slug', null_label='-- None --' ) @@ -99,7 +119,9 @@ class RIRForm(BootstrapMixin, forms.ModelForm): class Meta: model = RIR - fields = ['name', 'slug', 'is_private'] + fields = [ + 'name', 'slug', 'is_private', + ] class RIRCSVForm(forms.ModelForm): @@ -114,11 +136,17 @@ class RIRCSVForm(forms.ModelForm): class RIRFilterForm(BootstrapMixin, forms.Form): - is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[ - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), - ])) + is_private = forms.NullBooleanField( + required=False, + label='Private', + widget=forms.Select( + choices=[ + ('', '---------'), + ('True', 'Yes'), + ('False', 'No'), + ] + ) + ) # @@ -126,11 +154,15 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description', 'tags'] + fields = [ + 'prefix', 'rir', 'date_added', 'description', 'tags', + ] help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -154,19 +186,40 @@ class AggregateCSVForm(forms.ModelForm): class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) - rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') - date_added = forms.DateField(required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Aggregate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + rir = forms.ModelChoiceField( + queryset=RIR.objects.all(), + required=False, + label='RIR' + ) + date_added = forms.DateField( + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['date_added', 'description'] + nullable_fields = [ + 'date_added', 'description', + ] class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate - q = forms.CharField(required=False, label='Search') - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') + q = forms.CharField( + required=False, + label='Search' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family' + ) rir = FilterChoiceField( queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug', @@ -183,7 +236,9 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = Role - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class RoleCSVForm(forms.ModelForm): @@ -207,7 +262,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, label='Site', widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'} + attrs={ + 'filter-for': 'vlan_group', + 'nullable': 'true', + } ) ) vlan_group = ChainedModelChoiceField( @@ -219,7 +277,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): label='VLAN group', widget=APISelect( api_url='/api/ipam/vlan-groups/?site_id={{site}}', - attrs={'filter-for': 'vlan', 'nullable': 'true'} + attrs={ + 'filter-for': 'vlan', + 'nullable': 'true', + } ) ) vlan = ChainedModelChoiceField( @@ -231,7 +292,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, label='VLAN', widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', + display_field='display_name' ) ) tags = TagField(required=False) @@ -252,7 +314,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): initial['vlan_group'] = instance.vlan.group kwargs['initial'] = initial - super(PrefixForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -313,7 +375,7 @@ class PrefixCSVForm(forms.ModelForm): def clean(self): - super(PrefixCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') vlan_group = self.cleaned_data.get('vlan_group') @@ -347,35 +409,84 @@ class PrefixCSVForm(forms.ModelForm): class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) - is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Prefix.objects.all(), + widget=forms.MultipleHiddenInput() + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(PREFIX_STATUS_CHOICES), + required=False + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + is_pool = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Is a pool' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] + nullable_fields = [ + 'site', 'vrf', 'tenant', 'role', 'description', + ] class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix - q = forms.CharField(required=False, label='Search') - within_include = forms.CharField(required=False, label='Search within', widget=forms.TextInput(attrs={ - 'placeholder': 'Prefix', - })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') - mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length') + q = forms.CharField( + required=False, + label='Search' + ) + within_include = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label='Search within' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family' + ) + mask_length = forms.ChoiceField( + required=False, + choices=PREFIX_MASK_LENGTH_CHOICES, + label='Mask length' + ) vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('prefixes')), + queryset=VRF.objects.annotate( + filter_count=Count('prefixes') + ), to_field_name='rd', label='VRF', null_label='-- Global --' ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), + queryset=Tenant.objects.annotate( + filter_count=Count('prefixes') + ), to_field_name='slug', null_label='-- None --' ) @@ -386,16 +497,23 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('prefixes')), + queryset=Site.objects.annotate( + filter_count=Count('prefixes') + ), to_field_name='slug', null_label='-- None --' ) role = FilterChoiceField( - queryset=Role.objects.annotate(filter_count=Count('prefixes')), + queryset=Role.objects.annotate( + filter_count=Count('prefixes') + ), to_field_name='slug', null_label='-- None --' ) - expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') + expand = forms.BooleanField( + required=False, + label='Expand prefix hierarchy' + ) # @@ -412,7 +530,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) required=False, label='Site', widget=forms.Select( - attrs={'filter-for': 'nat_rack'} + attrs={ + 'filter-for': 'nat_rack' + } ) ) nat_rack = ChainedModelChoiceField( @@ -425,7 +545,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) widget=APISelect( api_url='/api/dcim/racks/?site_id={{nat_site}}', display_field='display_name', - attrs={'filter-for': 'nat_device', 'nullable': 'true'} + attrs={ + 'filter-for': 'nat_device', + 'nullable': 'true' + } ) ) nat_device = ChainedModelChoiceField( @@ -464,8 +587,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) obj_label='address' ) ) - primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM') - tags = TagField(required=False) + primary_for_parent = forms.BooleanField( + required=False, + label='Make this the primary IP for the device/VM' + ) + tags = TagField( + required=False + ) class Meta: model = IPAddress @@ -485,7 +613,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) initial['nat_device'] = instance.nat_inside.device kwargs['initial'] = initial - super(IPAddressForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -507,7 +635,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) self.initial['primary_for_parent'] = True def clean(self): - super(IPAddressForm, self).clean() + super().clean() # Primary IP assignment is only available if an interface has been assigned. if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): @@ -517,7 +645,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) def save(self, *args, **kwargs): - ipaddress = super(IPAddressForm, self).save(*args, **kwargs) + ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. if self.cleaned_data['primary_for_parent']: @@ -540,17 +668,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): - pattern = ExpandableIPAddressField(label='Address pattern') + pattern = ExpandableIPAddressField( + label='Address pattern' + ) class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant'] + fields = [ + 'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant', + ] def __init__(self, *args, **kwargs): - super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' @@ -614,8 +746,7 @@ class IPAddressCSVForm(forms.ModelForm): fields = IPAddress.csv_headers def clean(self): - - super(IPAddressCSVForm, self).clean() + super().clean() device = self.cleaned_data.get('device') virtual_machine = self.cleaned_data.get('virtual_machine') @@ -664,7 +795,7 @@ class IPAddressCSVForm(forms.ModelForm): name=self.cleaned_data['interface_name'] ) - ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs) + ipaddress = super().save(*args, **kwargs) # Set as primary for device/VM if self.cleaned_data['is_primary']: @@ -679,38 +810,86 @@ class IPAddressCSVForm(forms.ModelForm): class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False) - role = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + widget=forms.MultipleHiddenInput() + ) + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), + required=False + ) + role = forms.ChoiceField( + choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), + required=False + ) + description = forms.CharField( + max_length=100, required=False + ) class Meta: - nullable_fields = ['vrf', 'role', 'tenant', 'description'] + nullable_fields = [ + 'vrf', 'role', 'tenant', 'description', + ] class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global') - address = forms.CharField(label='IP Address') + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + empty_label='Global' + ) + address = forms.CharField( + label='IP Address' + ) class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress - q = forms.CharField(required=False, label='Search') - parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={ - 'placeholder': 'Prefix', - })) - family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family') - mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length') + q = forms.CharField( + required=False, + label='Search' + ) + parent = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + 'placeholder': 'Prefix', + } + ), + label='Parent Prefix' + ) + family = forms.ChoiceField( + required=False, + choices=IP_FAMILY_CHOICES, + label='Address family' + ) + mask_length = forms.ChoiceField( + required=False, + choices=IPADDRESS_MASK_LENGTH_CHOICES, + label='Mask length' + ) vrf = FilterChoiceField( - queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), + queryset=VRF.objects.annotate( + filter_count=Count('ip_addresses') + ), to_field_name='rd', label='VRF', null_label='-- Global --' ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), + queryset=Tenant.objects.annotate( + filter_count=Count('ip_addresses') + ), to_field_name='slug', null_label='-- None --' ) @@ -737,7 +916,9 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = VLANGroup - fields = ['site', 'name', 'slug'] + fields = [ + 'site', 'name', 'slug', + ] class VLANGroupCSVForm(forms.ModelForm): @@ -762,7 +943,9 @@ class VLANGroupCSVForm(forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), + queryset=Site.objects.annotate( + filter_count=Count('vlan_groups') + ), to_field_name='slug', null_label='-- Global --' ) @@ -777,7 +960,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): queryset=Site.objects.all(), required=False, widget=forms.Select( - attrs={'filter-for': 'group', 'nullable': 'true'} + attrs={ + 'filter-for': 'group', + 'nullable': 'true', + } ) ) group = ChainedModelChoiceField( @@ -795,7 +981,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags'] + fields = [ + 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', + ] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", @@ -852,8 +1040,7 @@ class VLANCSVForm(forms.ModelForm): } def clean(self): - - super(VLANCSVForm, self).clean() + super().clean() site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') @@ -864,39 +1051,75 @@ class VLANCSVForm(forms.ModelForm): self.instance.group = VLANGroup.objects.get(site=site, name=group_name) except VLANGroup.DoesNotExist: if site: - raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site)) + raise forms.ValidationError( + "VLAN group {} not found for site {}".format(group_name, site) + ) else: - raise forms.ValidationError("Global VLAN group {} not found".format(group_name)) + raise forms.ValidationError( + "Global VLAN group {} not found".format(group_name) + ) class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=add_blank_choice(VLAN_STATUS_CHOICES), required=False) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + widget=forms.MultipleHiddenInput() + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + group = forms.ModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + status = forms.ChoiceField( + choices=add_blank_choice(VLAN_STATUS_CHOICES), + required=False + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] + nullable_fields = [ + 'site', 'group', 'tenant', 'role', 'description', + ] class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('vlans')), + queryset=Site.objects.annotate( + filter_count=Count('vlans') + ), to_field_name='slug', null_label='-- Global --' ) group_id = FilterChoiceField( - queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), + queryset=VLANGroup.objects.annotate( + filter_count=Count('vlans') + ), label='VLAN group', null_label='-- None --' ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('vlans')), + queryset=Tenant.objects.annotate( + filter_count=Count('vlans') + ), to_field_name='slug', null_label='-- None --' ) @@ -907,7 +1130,9 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False ) role = FilterChoiceField( - queryset=Role.objects.annotate(filter_count=Count('vlans')), + queryset=Role.objects.annotate( + filter_count=Count('vlans') + ), to_field_name='slug', null_label='-- None --' ) @@ -918,19 +1143,22 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): # class ServiceForm(BootstrapMixin, CustomFieldForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Service - fields = ['name', 'protocol', 'port', 'ipaddresses', 'description', 'tags'] + fields = [ + 'name', 'protocol', 'port', 'ipaddresses', 'description', 'tags', + ] help_texts = { 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " "reachable via all IPs assigned to the device.", } def __init__(self, *args, **kwargs): - - super(ServiceForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Limit IP address choices to those assigned to interfaces of the parent device/VM if self.instance.device: @@ -962,10 +1190,27 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.MultipleHiddenInput) - protocol = forms.ChoiceField(choices=add_blank_choice(IP_PROTOCOL_CHOICES), required=False) - port = forms.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], required=False) - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Service.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(IP_PROTOCOL_CHOICES), + required=False + ) + port = forms.IntegerField( + validators=[ + MinValueValidator(1), + MaxValueValidator(65535), + ], + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] + nullable_fields = [ + 'site', 'group', 'tenant', 'role', 'description', + ] diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 9aca3c03b..e1de38a51 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import Lookup, Transform, IntegerField from django.db.models import lookups diff --git a/netbox/ipam/migrations/0001_initial.py b/netbox/ipam/migrations/0001_initial.py index f98d04952..567f991ec 100644 --- a/netbox/ipam/migrations/0001_initial.py +++ b/netbox/ipam/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py index 373e93d80..993020a12 100644 --- a/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-14 19:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py index c4271ea51..61d38a69b 100644 --- a/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique_squashed_0018_remove_service_uniqueness_constraint.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:12 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0003_ipam_add_vlangroups.py b/netbox/ipam/migrations/0003_ipam_add_vlangroups.py index 2e7157fe1..c9092f0f2 100644 --- a/netbox/ipam/migrations/0003_ipam_add_vlangroups.py +++ b/netbox/ipam/migrations/0003_ipam_add_vlangroups.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-15 16:22 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py b/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py index fef5ec0b3..d8f628c57 100644 --- a/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py +++ b/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-07-15 17:14 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0005_auto_20160725_1842.py b/netbox/ipam/migrations/0005_auto_20160725_1842.py index 17eee6e8c..726b89259 100644 --- a/netbox/ipam/migrations/0005_auto_20160725_1842.py +++ b/netbox/ipam/migrations/0005_auto_20160725_1842.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-25 18:42 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py index 8d519261d..9352e4872 100644 --- a/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py +++ b/netbox/ipam/migrations/0006_vrf_vlan_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-27 14:39 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py b/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py index eab3b9a47..dfe8fbb52 100644 --- a/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py +++ b/netbox/ipam/migrations/0007_prefix_ipaddress_add_tenant.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-28 15:32 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0008_prefix_change_order.py b/netbox/ipam/migrations/0008_prefix_change_order.py index 3ad3eb9e3..ea219da19 100644 --- a/netbox/ipam/migrations/0008_prefix_change_order.py +++ b/netbox/ipam/migrations/0008_prefix_change_order.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-09-15 16:08 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0009_ipaddress_add_status.py b/netbox/ipam/migrations/0009_ipaddress_add_status.py index ad876c3b6..b28590730 100644 --- a/netbox/ipam/migrations/0009_ipaddress_add_status.py +++ b/netbox/ipam/migrations/0009_ipaddress_add_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-10-21 15:44 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0010_ipaddress_help_texts.py b/netbox/ipam/migrations/0010_ipaddress_help_texts.py index a1e05171d..2a7e06335 100644 --- a/netbox/ipam/migrations/0010_ipaddress_help_texts.py +++ b/netbox/ipam/migrations/0010_ipaddress_help_texts.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-11-01 17:46 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import ipam.fields diff --git a/netbox/ipam/migrations/0011_rir_add_is_private.py b/netbox/ipam/migrations/0011_rir_add_is_private.py index ad7732653..d8b81d484 100644 --- a/netbox/ipam/migrations/0011_rir_add_is_private.py +++ b/netbox/ipam/migrations/0011_rir_add_is_private.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-06 18:27 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0012_services.py b/netbox/ipam/migrations/0012_services.py index bb6274408..12b2cf673 100644 --- a/netbox/ipam/migrations/0012_services.py +++ b/netbox/ipam/migrations/0012_services.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10 on 2016-12-15 20:22 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0013_prefix_add_is_pool.py b/netbox/ipam/migrations/0013_prefix_add_is_pool.py index fd1493610..194bcb651 100644 --- a/netbox/ipam/migrations/0013_prefix_add_is_pool.py +++ b/netbox/ipam/migrations/0013_prefix_add_is_pool.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2016-12-27 19:34 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion import ipam.fields diff --git a/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py b/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py index adc8e606c..3f5f48437 100644 --- a/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py +++ b/netbox/ipam/migrations/0014_ipaddress_status_add_deprecated.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-01-23 19:10 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0015_global_vlans.py b/netbox/ipam/migrations/0015_global_vlans.py index 18d82cbaf..5471e33e2 100644 --- a/netbox/ipam/migrations/0015_global_vlans.py +++ b/netbox/ipam/migrations/0015_global_vlans.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.4 on 2017-02-21 18:45 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0016_unicode_literals.py b/netbox/ipam/migrations/0016_unicode_literals.py index bb29542ad..6807bc555 100644 --- a/netbox/ipam/migrations/0016_unicode_literals.py +++ b/netbox/ipam/migrations/0016_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - import django.core.validators from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0017_ipaddress_roles.py b/netbox/ipam/migrations/0017_ipaddress_roles.py index d91c3daa9..11bf37294 100644 --- a/netbox/ipam/migrations/0017_ipaddress_roles.py +++ b/netbox/ipam/migrations/0017_ipaddress_roles.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.1 on 2017-06-16 19:37 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py b/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py index 77e083ef3..3d3184354 100644 --- a/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py +++ b/netbox/ipam/migrations/0018_remove_service_uniqueness_constraint.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.3 on 2017-08-03 19:37 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py index 955ff8b4a..f8ffbca11 100644 --- a/netbox/ipam/migrations/0019_virtualization.py +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 15:44 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py index c8292bbc0..e271685a0 100644 --- a/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py +++ b/netbox/ipam/migrations/0019_virtualization_squashed_0020_ipaddress_add_role_carp.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:14 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py index 9d16be049..e15c12a32 100644 --- a/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py +++ b/netbox/ipam/migrations/0020_ipaddress_add_role_carp.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-09 20:02 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/migrations/0021_vrf_ordering.py b/netbox/ipam/migrations/0021_vrf_ordering.py index 878c02d8c..7f74115b6 100644 --- a/netbox/ipam/migrations/0021_vrf_ordering.py +++ b/netbox/ipam/migrations/0021_vrf_ordering.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.9 on 2018-02-07 18:37 -from __future__ import unicode_literals - from django.db import migrations diff --git a/netbox/ipam/migrations/0022_tags.py b/netbox/ipam/migrations/0022_tags.py index 14a508317..642bccc05 100644 --- a/netbox/ipam/migrations/0022_tags.py +++ b/netbox/ipam/migrations/0022_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/ipam/migrations/0023_change_logging.py b/netbox/ipam/migrations/0023_change_logging.py index d548fdf15..afb732d64 100644 --- a/netbox/ipam/migrations/0023_change_logging.py +++ b/netbox/ipam/migrations/0023_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:14 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index ef3bc6c30..ca3f812a3 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -9,7 +7,6 @@ from django.db import models from django.db.models import Q from django.db.models.expressions import RawSQL from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from dcim.models import Interface @@ -20,7 +17,6 @@ from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet -@python_2_unicode_compatible class VRF(ChangeLoggedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -67,7 +63,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): verbose_name_plural = 'VRFs' def __str__(self): - return self.display_name or super(VRF, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('ipam:vrf', args=[self.pk]) @@ -88,7 +84,6 @@ class VRF(ChangeLoggedModel, CustomFieldModel): return None -@python_2_unicode_compatible class RIR(ChangeLoggedModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -128,7 +123,6 @@ class RIR(ChangeLoggedModel): ) -@python_2_unicode_compatible class Aggregate(ChangeLoggedModel, CustomFieldModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize @@ -204,7 +198,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): if self.prefix: # Infer address family from IPNetwork object self.family = self.prefix.version - super(Aggregate, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): return ( @@ -223,7 +217,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): return int(float(child_prefixes.size) / self.prefix.size * 100) -@python_2_unicode_compatible class Role(ChangeLoggedModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -256,7 +249,6 @@ class Role(ChangeLoggedModel): ) -@python_2_unicode_compatible class Prefix(ChangeLoggedModel, CustomFieldModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and @@ -377,7 +369,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): self.prefix = self.prefix.cidr # Infer address family from IPNetwork object self.family = self.prefix.version - super(Prefix, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): return ( @@ -492,11 +484,10 @@ class IPAddressManager(models.Manager): then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each IP address as a /32 or /128. """ - qs = super(IPAddressManager, self).get_queryset() + qs = super().get_queryset() return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host') -@python_2_unicode_compatible class IPAddress(ChangeLoggedModel, CustomFieldModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is @@ -614,7 +605,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): if self.address: # Infer address family from IPAddress object self.family = self.address.version - super(IPAddress, self).save(*args, **kwargs) + super().save(*args, **kwargs) def to_csv(self): @@ -658,7 +649,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): return ROLE_CHOICE_CLASSES[self.role] -@python_2_unicode_compatible class VLANGroup(ChangeLoggedModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -710,7 +700,6 @@ class VLANGroup(ChangeLoggedModel): return None -@python_2_unicode_compatible class VLAN(ChangeLoggedModel, CustomFieldModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned @@ -784,7 +773,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): verbose_name_plural = 'VLANs' def __str__(self): - return self.display_name or super(VLAN, self).__str__() + return self.display_name or super().__str__() def get_absolute_url(self): return reverse('ipam:vlan', args=[self.pk]) @@ -826,7 +815,6 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): ) -@python_2_unicode_compatible class Service(ChangeLoggedModel, CustomFieldModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index f606ab1b4..bfb2525f2 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from utilities.sql import NullsFirstQuerySet diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 261c047df..284bcb4ae 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor @@ -466,7 +464,7 @@ class InterfaceVLANTable(BaseTable): def __init__(self, interface, *args, **kwargs): self.interface = interface - super(InterfaceVLANTable, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 67b7e123e..d57cb728f 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -14,7 +12,7 @@ class VRFTest(APITestCase): def setUp(self): - super(VRFTest, self).setUp() + super().setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2') @@ -115,7 +113,7 @@ class RIRTest(APITestCase): def setUp(self): - super(RIRTest, self).setUp() + super().setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -216,7 +214,7 @@ class AggregateTest(APITestCase): def setUp(self): - super(AggregateTest, self).setUp() + super().setUp() self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1') self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2') @@ -319,7 +317,7 @@ class RoleTest(APITestCase): def setUp(self): - super(RoleTest, self).setUp() + super().setUp() self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1') self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2') @@ -420,7 +418,7 @@ class PrefixTest(APITestCase): def setUp(self): - super(PrefixTest, self).setUp() + super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') @@ -568,7 +566,7 @@ class PrefixTest(APITestCase): # Try to create one more prefix response = self.client.post(url, {'prefix_length': 30}, **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) def test_create_multiple_available_prefixes(self): @@ -585,7 +583,7 @@ class PrefixTest(APITestCase): {'prefix_length': 30, 'description': 'Test Prefix 5'}, ] response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) # Verify that no prefixes were created (the entire /28 is still available) @@ -630,7 +628,7 @@ class PrefixTest(APITestCase): # Try to create one more IP response = self.client.post(url, {}, **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) def test_create_multiple_available_ips(self): @@ -641,7 +639,7 @@ class PrefixTest(APITestCase): # Try to create nine IPs (only eight are available) data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertIn('detail', response.data) # Verify that no IPs were created (eight are still available) @@ -659,7 +657,7 @@ class IPAddressTest(APITestCase): def setUp(self): - super(IPAddressTest, self).setUp() + super().setUp() self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1') self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24')) @@ -758,7 +756,7 @@ class VLANGroupTest(APITestCase): def setUp(self): - super(VLANGroupTest, self).setUp() + super().setUp() self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2') @@ -859,7 +857,7 @@ class VLANTest(APITestCase): def setUp(self): - super(VLANTest, self).setUp() + super().setUp() self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') @@ -960,7 +958,7 @@ class ServiceTest(APITestCase): def setUp(self): - super(ServiceTest, self).setUp() + super().setUp() site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index d17e8f5ef..f7f1705ff 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.core.exceptions import ValidationError from django.test import TestCase, override_settings diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 700d78ae4..c2f7badd3 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,12 +1,9 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView from . import views from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF - app_name = 'ipam' urlpatterns = [ diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2e3e0105c..3a4c36173 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import netaddr from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -338,7 +336,7 @@ class AggregateView(View): prefix_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(prefix_table) @@ -514,7 +512,7 @@ class PrefixPrefixesView(View): prefix_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(prefix_table) @@ -553,7 +551,7 @@ class PrefixIPAddressesView(View): ip_table.columns.show('pk') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(ip_table) @@ -717,7 +715,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View): if 'interface' not in request.GET: return redirect('ipam:ipaddress_add') - return super(IPAddressAssignView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get(self, request): @@ -842,7 +840,7 @@ class VLANGroupVLANsView(View): vlan_table.columns.hide('group') paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(vlan_table) @@ -901,7 +899,7 @@ class VLANMembersView(View): members_table = tables.VLANMemberTable(members) paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(members_table) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 34faba233..61796aabd 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib.admin import AdminSite -from django.contrib.auth.models import Group, User from django.contrib.auth.admin import GroupAdmin, UserAdmin +from django.contrib.auth.models import Group, User from taggit.admin import TagAdmin from taggit.models import Tag diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index a0a9e9146..60c493be7 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination @@ -59,16 +57,15 @@ class TokenPermissions(DjangoModelPermissions): """ def __init__(self): # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. - from django.conf import settings self.authenticated_users_only = settings.LOGIN_REQUIRED - super(TokenPermissions, self).__init__() + super().__init__() def has_permission(self, request, view): # If token authentication is in use, verify that the token allows write operations (for unsafe methods). if request.method not in SAFE_METHODS and isinstance(request.auth, Token): if not request.auth.write_enabled: return False - return super(TokenPermissions, self).has_permission(request, view) + return super().has_permission(request, view) # @@ -84,10 +81,17 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): - try: - self.count = queryset.count() - except (AttributeError, TypeError): + if hasattr(queryset, 'all'): + # TODO: This breaks filtering by annotated values + # Make a clone of the queryset with any annotations stripped (performance hack) + qs = queryset.all() + qs.query.annotations.clear() + self.count = qs.count() + + else: + # We're dealing with an iterable, not a QuerySet self.count = len(queryset) + self.limit = self.get_limit(request) self.offset = self.get_offset(request) self.request = request @@ -128,7 +132,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): if not self.limit: return None - return super(OptionalLimitOffsetPagination, self).get_next_link() + return super().get_next_link() def get_previous_link(self): @@ -136,7 +140,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): if not self.limit: return None - return super(OptionalLimitOffsetPagination, self).get_previous_link() + return super().get_previous_link() # diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 23d6ba221..d7a9cf2ed 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -91,6 +91,10 @@ LOGGING = {} # are permitted to access most data in NetBox (excluding secrets) but not make any changes. LOGIN_REQUIRED = False +# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to +# re-authenticate. (Default: 1209600 [14 days]) +LOGIN_TIMEOUT = None + # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False @@ -121,10 +125,6 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False -# The Webhook event backend is disabled by default. Set this to True to enable it. Note that this requires a Redis -# database be configured and accessible by NetBox (see `REDIS` below). -WEBHOOKS_ENABLED = False - # Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled. REDIS = { 'HOST': 'localhost', @@ -138,9 +138,18 @@ REDIS = { # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' +# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use +# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only +# database access.) Note that the user as which NetBox runs must have read and write permissions to this path. +SESSION_FILE_PATH = None + # Time zone (default: UTC) TIME_ZONE = 'UTC' +# The webhooks backend is disabled by default. Set this to True to enable it. Note that this requires a Redis +# database be configured and accessible by NetBox. +WEBHOOKS_ENABLED = False + # Date/time formatting. See the following link for supported formats: # https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date DATE_FORMAT = 'N j, Y' diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index 434377024..d5ab09410 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from utilities.forms import BootstrapMixin diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index be34f2be3..7b004ce8f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -7,6 +7,13 @@ import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured +# Check for Python 3.5+ +if sys.version_info < (3, 5): + raise RuntimeError( + "NetBox requires Python 3.5 or higher (current: Python {})".format(sys.version.split()[0]) + ) + +# Check for configuration file try: from netbox import configuration except ImportError: @@ -14,15 +21,8 @@ except ImportError: "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." ) -# Raise a deprecation warning for Python 2.x -if sys.version_info[0] < 3: - warnings.warn( - "Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest " - "opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.", - DeprecationWarning - ) -VERSION = '2.4.9' +VERSION = '2.5-beta2' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -55,6 +55,7 @@ ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EMAIL = getattr(configuration, 'EMAIL', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') @@ -66,6 +67,7 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REDIS = getattr(configuration, 'REDIS', {}) +SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') @@ -112,6 +114,17 @@ DATABASES = { 'default': configuration.DATABASE, } +# Sessions +if LOGIN_TIMEOUT is not None: + if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0: + raise ImproperlyConfigured( + "LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT) + ) + # Django default is 1209600 seconds (14 days) + SESSION_COOKIE_AGE = LOGIN_TIMEOUT +if SESSION_FILE_PATH is not None: + SESSION_ENGINE = 'django.contrib.sessions.backends.file' + # Redis REDIS_HOST = REDIS.get('HOST', 'localhost') REDIS_PORT = REDIS.get('PORT', 6379) @@ -235,7 +248,7 @@ SECRETS_MIN_PUBKEY_SIZE = 2048 # Django filters FILTERS_NULL_CHOICE_LABEL = 'None' -FILTERS_NULL_CHOICE_VALUE = '0' # Must be a string +FILTERS_NULL_CHOICE_VALUE = 'null' # Django REST framework (API) REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9354e24b9..45c99beb9 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,10 +1,8 @@ -from __future__ import unicode_literals - from django.conf import settings from django.conf.urls import include, url from django.views.static import serve -from drf_yasg.views import get_schema_view from drf_yasg import openapi +from drf_yasg.views import get_schema_view from netbox.views import APIRootView, HomeView, SearchView from users.views import LoginView, LogoutView diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 75d3dd182..263acb8ee 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from collections import OrderedDict -from django.db.models import Count +from django.db.models import Count, F from django.shortcuts import render from django.views.generic import View from rest_framework.response import Response @@ -16,8 +14,7 @@ from dcim.filters import ( DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter ) from dcim.models import ( - ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site, - VirtualChassis + Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis ) from dcim.tables import ( DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable @@ -159,6 +156,18 @@ class HomeView(View): def get(self, request): + connected_consoleports = ConsolePort.objects.filter( + connected_endpoint__isnull=False + ) + connected_powerports = PowerPort.objects.filter( + connected_endpoint__isnull=False + ) + connected_interfaces = Interface.objects.filter( + _connected_interface__isnull=False, + pk__lt=F('_connected_interface') + ) + cables = Cable.objects.all() + stats = { # Organization @@ -168,9 +177,10 @@ class HomeView(View): # DCIM 'rack_count': Rack.objects.count(), 'device_count': Device.objects.count(), - 'interface_connections_count': InterfaceConnection.objects.count(), - 'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(), - 'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(), + 'interface_connections_count': connected_interfaces.count(), + 'cable_count': cables.count(), + 'console_connections_count': connected_consoleports.count(), + 'power_connections_count': connected_powerports.count(), # IPAM 'vrf_count': VRF.objects.count(), diff --git a/netbox/netbox/wsgi.py b/netbox/netbox/wsgi.py index ecfd81d9a..137f057c0 100644 --- a/netbox/netbox/wsgi.py +++ b/netbox/netbox/wsgi.py @@ -2,7 +2,6 @@ import os from django.core.wsgi import get_wsgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") application = get_wsgi_application() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 56104515c..155dd21b7 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -429,6 +429,10 @@ table.report th a { } /* Misc */ +.color-block { + display: block; + width: 80px; +} .text-nowrap { white-space: nowrap; } diff --git a/netbox/project-static/js/cabletrace.js b/netbox/project-static/js/cabletrace.js new file mode 100644 index 000000000..2307cef87 --- /dev/null +++ b/netbox/project-static/js/cabletrace.js @@ -0,0 +1,24 @@ +$('#cabletrace_modal').on('show.bs.modal', function (event) { + var button = $(event.relatedTarget); + var obj = button.data('obj'); + var url = button.data('url'); + var modal_title = $(this).find('.modal-title'); + var modal_body = $(this).find('.modal-body'); + modal_title.text(obj); + modal_body.empty(); + $.ajax({ + url: url, + dataType: 'json', + success: function(json) { + $.each(json, function(i, segment) { + modal_body.append( + '
' + + '
' + segment[0].device.name + '
' + segment[0].name + '
' + + '
Cable #' + segment[1].id + '
' + + '
' + segment[2].device.name + '
' + segment[2].name + '
' + + '

' + ); + }) + } + }); +}); diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 6cb621071..89bc3aee1 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -91,15 +91,31 @@ $(document).ready(function() { var filter_regex = /\{\{([a-z_]+)\}\}/g; var match; var rendered_url = api_url; + var filter_field; while (match = filter_regex.exec(api_url)) { - var filter_field = $('#id_' + match[1]); - if (filter_field.val()) { + filter_field = $('#id_' + match[1]); + var custom_attr = $('option:selected', filter_field).attr('api-value'); + if (custom_attr) { + rendered_url = rendered_url.replace(match[0], custom_attr); + } else if (filter_field.val()) { rendered_url = rendered_url.replace(match[0], filter_field.val()); } else if (filter_field.attr('nullable') == 'true') { rendered_url = rendered_url.replace(match[0], '0'); } } + // Account for any conditional URL append strings + $.each(child_field[0].attributes, function(index, attr){ + if (attr.name.includes("data-url-conditional-append-")){ + var conditional = attr.name.split("data-url-conditional-append-")[1].split("__"); + var field = $("#id_" + conditional[0]); + var field_value = conditional[1]; + if ($('option:selected', field).attr('api-value') === field_value){ + rendered_url = rendered_url + attr.value; + } + } + }) + // If all URL variables have been replaced, make the API call if (rendered_url.search('{{') < 0) { console.log(child_name + ": Fetching " + rendered_url); diff --git a/netbox/project-static/js/livesearch.js b/netbox/project-static/js/livesearch.js index 2d5afe700..92902acfd 100644 --- a/netbox/project-static/js/livesearch.js +++ b/netbox/project-static/js/livesearch.js @@ -42,8 +42,8 @@ $(document).ready(function() { event.preventDefault(); search_field.val(ui.item.label); select_fields.val(''); - select_fields.attr('disabled', 'disabled'); real_field.empty(); + select_fields.attr('disabled', 'disabled'); real_field.append($("").attr('value', ui.item.value).text(ui.item.label)); real_field.change(); // Disable parent selection fields diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 4eeac519c..94ede4545 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import admin, messages from django.shortcuts import redirect, render @@ -23,7 +21,7 @@ class UserKeyAdmin(admin.ModelAdmin): def get_actions(self, request): # Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model. - actions = super(UserKeyAdmin, self).get_actions(request) + actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] if not request.user.has_perm('secrets.activate_userkey'): diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py new file mode 100644 index 000000000..819546c63 --- /dev/null +++ b/netbox/secrets/api/nested_serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from secrets.models import SecretRole +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedSecretRoleSerializer' +] + + +class NestedSecretRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') + + class Meta: + model = SecretRole + fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index ee7217b63..1faf85dcf 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,17 +1,16 @@ -from __future__ import unicode_literals - from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from dcim.api.serializers import NestedDeviceSerializer +from dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole -from utilities.api import ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer +from .nested_serializers import * # -# SecretRoles +# Secrets # class SecretRoleSerializer(ValidatedModelSerializer): @@ -21,18 +20,6 @@ class SecretRoleSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedSecretRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') - - class Meta: - model = SecretRole - fields = ['id', 'url', 'name', 'slug'] - - -# -# Secrets -# - class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() @@ -62,6 +49,6 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): validator(data) # Enforce model validation - super(SecretSerializer, self).validate(data) + super().validate(data) return data diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 2a24c445a..def87b3a1 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,15 +15,15 @@ router = routers.DefaultRouter() router.APIRootView = SecretsRootView # Field choices -router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice') # Secrets router.register(r'secret-roles', views.SecretRoleViewSet) router.register(r'secrets', views.SecretViewSet) # Miscellaneous -router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key') -router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair') +router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') +router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') app_name = 'secrets-api' urlpatterns = router.urls diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 01567be8b..0c164de07 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from Crypto.PublicKey import RSA @@ -37,7 +35,7 @@ class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.all() serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] - filter_class = filters.SecretRoleFilter + filterset_class = filters.SecretRoleFilter # @@ -51,21 +49,21 @@ class SecretViewSet(ModelViewSet): 'role__users', 'role__groups', 'tags', ) serializer_class = serializers.SecretSerializer - filter_class = filters.SecretFilter + filterset_class = filters.SecretFilter master_key = None def get_serializer_context(self): # Make the master key available to the serializer for encrypting plaintext values - context = super(SecretViewSet, self).get_serializer_context() + context = super().get_serializer_context() context['master_key'] = self.master_key return context def initial(self, request, *args, **kwargs): - super(SecretViewSet, self).initial(request, *args, **kwargs) + super().initial(request, *args, **kwargs) if request.user.is_authenticated: diff --git a/netbox/secrets/apps.py b/netbox/secrets/apps.py index bc3714966..eec54bd7f 100644 --- a/netbox/secrets/apps.py +++ b/netbox/secrets/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py index 0b9ebc16e..e2f44ac90 100644 --- a/netbox/secrets/decorators.py +++ b/netbox/secrets/decorators.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.shortcuts import redirect diff --git a/netbox/secrets/exceptions.py b/netbox/secrets/exceptions.py index f014d8a14..11433d41e 100644 --- a/netbox/secrets/exceptions.py +++ b/netbox/secrets/exceptions.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - class InvalidKey(Exception): """ diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index aa7e02e43..5880fb9f9 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.db.models import Q @@ -17,7 +15,10 @@ class SecretRoleFilter(django_filters.FilterSet): class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -27,7 +28,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=SecretRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -37,7 +38,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='device__name', + field_name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 59e637a18..4e84ff78d 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms @@ -41,7 +39,9 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = SecretRole - fields = ['name', 'slug', 'users', 'groups'] + fields = [ + 'name', 'slug', 'users', 'groups', + ] class SecretRoleCSVForm(forms.ModelForm): @@ -64,7 +64,11 @@ class SecretForm(BootstrapMixin, CustomFieldForm): max_length=65535, required=False, label='Plaintext', - widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}) + widget=forms.PasswordInput( + attrs={ + 'class': 'requires-session-key', + } + ) ) plaintext2 = forms.CharField( max_length=65535, @@ -72,15 +76,18 @@ class SecretForm(BootstrapMixin, CustomFieldForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Secret - fields = ['role', 'name', 'plaintext', 'plaintext2', 'tags'] + fields = [ + 'role', 'name', 'plaintext', 'plaintext2', 'tags', + ] def __init__(self, *args, **kwargs): - - super(SecretForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # A plaintext value is required when creating a new Secret if not self.instance.pk: @@ -124,25 +131,41 @@ class SecretCSVForm(forms.ModelForm): } def save(self, *args, **kwargs): - s = super(SecretCSVForm, self).save(*args, **kwargs) + s = super().save(*args, **kwargs) s.plaintext = str(self.cleaned_data['plaintext']) return s class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) - role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) - name = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Secret.objects.all(), + widget=forms.MultipleHiddenInput() + ) + role = forms.ModelChoiceField( + queryset=SecretRole.objects.all(), + required=False + ) + name = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['name'] + nullable_fields = [ + 'name', + ] class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Secret - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) role = FilterChoiceField( - queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), + queryset=SecretRole.objects.annotate( + filter_count=Count('secrets') + ), to_field_name='slug' ) @@ -171,5 +194,15 @@ class UserKeyForm(BootstrapMixin, forms.ModelForm): class ActivateUserKeyForm(forms.Form): - _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys') - secret_key = forms.CharField(label='Your private key', widget=forms.Textarea(attrs={'class': 'vLargeTextField'})) + _selected_action = forms.ModelMultipleChoiceField( + queryset=UserKey.objects.all(), + label='User Keys' + ) + secret_key = forms.CharField( + widget=forms.Textarea( + attrs={ + 'class': 'vLargeTextField', + } + ), + label='Your private key' + ) diff --git a/netbox/secrets/hashers.py b/netbox/secrets/hashers.py index 49da1605d..fc5066fc6 100644 --- a/netbox/secrets/hashers.py +++ b/netbox/secrets/hashers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth.hashers import PBKDF2PasswordHasher diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 8dc0d54c6..1281a266a 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.7 on 2016-06-22 18:21 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py index fb7d37431..04db89e7c 100644 --- a/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py +++ b/netbox/secrets/migrations/0001_initial_squashed_0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-08-01 17:45 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/secrets/migrations/0002_userkey_add_session_key.py b/netbox/secrets/migrations/0002_userkey_add_session_key.py index 4cd885cfb..03abfb70e 100644 --- a/netbox/secrets/migrations/0002_userkey_add_session_key.py +++ b/netbox/secrets/migrations/0002_userkey_add_session_key.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-04-27 15:26 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/secrets/migrations/0003_unicode_literals.py b/netbox/secrets/migrations/0003_unicode_literals.py index b8b7956d8..48be221c5 100644 --- a/netbox/secrets/migrations/0003_unicode_literals.py +++ b/netbox/secrets/migrations/0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/secrets/migrations/0004_tags.py b/netbox/secrets/migrations/0004_tags.py index ac952dc92..bdba79804 100644 --- a/netbox/secrets/migrations/0004_tags.py +++ b/netbox/secrets/migrations/0004_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/secrets/migrations/0005_change_logging.py b/netbox/secrets/migrations/0005_change_logging.py index 947087934..d920e6fb2 100644 --- a/netbox/secrets/migrations/0005_change_logging.py +++ b/netbox/secrets/migrations/0005_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:29 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 6beb86c9e..8190cd1dd 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os import sys @@ -13,7 +11,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from django.utils.encoding import force_bytes, python_2_unicode_compatible +from django.utils.encoding import force_bytes from taggit.managers import TaggableManager from extras.models import CustomFieldModel @@ -50,7 +48,6 @@ def decrypt_master_key(master_key_cipher, private_key): return cipher.decrypt(master_key_cipher) -@python_2_unicode_compatible class UserKey(models.Model): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted @@ -88,7 +85,7 @@ class UserKey(models.Model): ) def __init__(self, *args, **kwargs): - super(UserKey, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Store the initial public_key and master_key_cipher to check for changes on save(). self.__initial_public_key = self.public_key @@ -128,7 +125,7 @@ class UserKey(models.Model): ) }) - super(UserKey, self).clean() + super().clean() def save(self, *args, **kwargs): @@ -141,7 +138,7 @@ class UserKey(models.Model): master_key = generate_random_key() self.master_key_cipher = encrypt_master_key(master_key, self.public_key) - super(UserKey, self).save(*args, **kwargs) + super().save(*args, **kwargs) def delete(self, *args, **kwargs): @@ -151,7 +148,7 @@ class UserKey(models.Model): raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets " "inaccessible.") - super(UserKey, self).delete(*args, **kwargs) + super().delete(*args, **kwargs) def is_filled(self): """ @@ -188,7 +185,6 @@ class UserKey(models.Model): self.save() -@python_2_unicode_compatible class SessionKey(models.Model): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. @@ -234,7 +230,7 @@ class SessionKey(models.Model): # Encrypt master key using the session key self.cipher = strxor.strxor(self.key, master_key) - super(SessionKey, self).save(*args, **kwargs) + super().save(*args, **kwargs) def get_master_key(self, session_key): @@ -259,7 +255,6 @@ class SessionKey(models.Model): return session_key -@python_2_unicode_compatible class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles @@ -312,7 +307,6 @@ class SecretRole(ChangeLoggedModel): return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() -@python_2_unicode_compatible class Secret(ChangeLoggedModel, CustomFieldModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible @@ -362,7 +356,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel): def __init__(self, *args, **kwargs): self.plaintext = kwargs.pop('plaintext', None) - super(Secret, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def __str__(self): if self.role and self.device and self.name: diff --git a/netbox/secrets/querysets.py b/netbox/secrets/querysets.py index c5595e1d3..c9732c5fe 100644 --- a/netbox/secrets/querysets.py +++ b/netbox/secrets/querysets.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db.models import QuerySet diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 4cfb1a6ea..39d260a6d 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from utilities.tables import BaseTable, ToggleColumn diff --git a/netbox/secrets/templatetags/secret_helpers.py b/netbox/secrets/templatetags/secret_helpers.py index 0e1ff554c..142c0d2cb 100644 --- a/netbox/secrets/templatetags/secret_helpers.py +++ b/netbox/secrets/templatetags/secret_helpers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index d8d156ef3..c260f1a48 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from django.urls import reverse @@ -53,7 +51,7 @@ class SecretRoleTest(APITestCase): def setUp(self): - super(SecretRoleTest, self).setUp() + super().setUp() self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') @@ -154,7 +152,7 @@ class SecretTest(APITestCase): def setUp(self): - super(SecretTest, self).setUp() + super().setUp() userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -296,7 +294,7 @@ class GetSessionKeyTest(APITestCase): def setUp(self): - super(GetSessionKeyTest, self).setUp() + super().setUp() userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py index 2fb7c3781..b3ba0cee1 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals import string from Crypto.PublicKey import RSA diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 952725b54..e1ce2b8f2 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d15c9cbc2..91d8caf0d 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 from django.contrib import messages @@ -230,7 +228,7 @@ class SecretBulkImportView(BulkImportView): messages.error(request, "No session key found for this user.") if self.master_key is not None: - return super(SecretBulkImportView, self).post(request) + return super().post(request) else: messages.error(request, "Invalid private key! Unable to encrypt secret data.") diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 1da608a48..c09061c10 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -1,4 +1,4 @@ -{% load static from staticfiles %} +{% load static %} diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 2a9da7b15..f935f078e 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -1,4 +1,4 @@ -{% load static from staticfiles %} +{% load static %} {% load helpers %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 5b15782c9..edbab3ed4 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -95,33 +95,15 @@ Install Date - - {% if circuit.install_date %} - {{ circuit.install_date }} - {% else %} - N/A - {% endif %} - + {{ circuit.install_date|placeholder }} Commit Rate - - {% if circuit.commit_rate %} - {{ circuit.commit_rate|humanize_speed }} - {% else %} - N/A - {% endif %} - + {{ circuit.commit_rate|humanize_speed|placeholder }} Description - - {% if circuit.description %} - {{ circuit.description }} - {% else %} - N/A - {% endif %} - + {{ circuit.description|placeholder }} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index a2c7d966f..2bbc4695d 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load staticfiles %} +{% load static %} {% load form_helpers %} {% block content %} @@ -41,9 +41,6 @@ {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.device %} - {% render_field form.interface %}
@@ -71,6 +68,7 @@
{% render_field form.xconnect_id %} {% render_field form.pp_info %} + {% render_field form.description %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index a1b236dcc..a3cb09b25 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -39,10 +39,27 @@ Termination - {% if termination.interface %} - {{ termination.interface.device }} - {{ termination.interface }} + {% if termination.cable %} + {% if perms.dcim.delete_cable %} + + {% endif %} + {{ termination.cable }} + {% if termination.connected_endpoint %} + to {{ termination.connected_endpoint.device }} + {{ termination.connected_endpoint }} + {% endif %} {% else %} + {% if perms.circuits.add_cable %} + + {% endif %} Not defined {% endif %} @@ -61,37 +78,29 @@ IP Addressing - {% if termination.interface %} - {% for ip in termination.interface.ip_addresses.all %} + {% if termination.connected_endpoint %} + {% for ip in termination.connected_endpoint.ip_addresses.all %} {% if not forloop.first %}
{% endif %} {{ ip }} ({{ ip.vrf|default:"Global" }}) {% empty %} None {% endfor %} {% else %} - N/A + {% endif %} Cross-Connect - - {% if termination.xconnect_id %} - {{ termination.xconnect_id }} - {% else %} - N/A - {% endif %} - + {{ termination.xconnect_id|placeholder }} Patch Panel/Port - - {% if termination.pp_info %} - {{ termination.pp_info }} - {% else %} - N/A - {% endif %} - + {{ termination.pp_info|placeholder }} + + + Description + {{ termination.description|placeholder }} {% else %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 157c66918..46fd8afc7 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load helpers %} {% block title %}{{ provider }}{% endblock %} @@ -67,23 +67,11 @@ - + - + @@ -91,29 +79,17 @@ {% if provider.portal_url %} {{ provider.portal_url }} {% else %} - N/A + {% endif %} - + - + @@ -205,7 +181,7 @@ -{% include 'inc/graphs_modal.html' %} +{% include 'inc/modal.html' with modal_name='graphs' %} {% endblock %} {% block javascript %} diff --git a/netbox/templates/dcim/bulk_disconnect.html b/netbox/templates/dcim/bulk_disconnect.html index 82cc86a7a..e82d880ad 100644 --- a/netbox/templates/dcim/bulk_disconnect.html +++ b/netbox/templates/dcim/bulk_disconnect.html @@ -4,7 +4,7 @@ {% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %} {% block message %} -

Are you sure you want to disconnect all {{ selected_objects|length }} of these {{ obj_type_plural }} on {{ device }}?

+

Are you sure you want to disconnect these {{ selected_objects|length }} {{ obj_type_plural }}?

    {% for obj in selected_objects %}
  • {{ obj }}
  • diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html new file mode 100644 index 000000000..e1d3e4d8b --- /dev/null +++ b/netbox/templates/dcim/cable.html @@ -0,0 +1,94 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    + {% if perms.dcim.change_cable %} + + Edit this cable + + {% endif %} + {% if perms.dcim.delete_cable %} + + Delete this cable + + {% endif %} +
    +

    {% block title %}Cable {{ cable }}{% endblock %}

    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cable +
    +
ASN - {% if provider.asn %} - {{ provider.asn }} - {% else %} - N/A - {% endif %} - {{ provider.asn|placeholder }}
Account - {% if provider.account %} - {{ provider.account }} - {% else %} - N/A - {% endif %} - {{ provider.account|placeholder }}
Customer Portal
NOC Contact - {% if provider.noc_contact %} - {{ provider.noc_contact|linebreaksbr }} - {% else %} - N/A - {% endif %} - {{ provider.noc_contact|linebreaksbr|placeholder }}
Admin Contact - {% if provider.admin_contact %} - {{ provider.admin_contact|linebreaksbr }} - {% else %} - N/A - {% endif %} - {{ provider.admin_contact|linebreaksbr|placeholder }}
Circuits
+ + + + + + + + + + + + + + + + + + + + +
Type{{ cable.get_type_display }}
Status{{ cable.get_status_display }}
Label{{ cable.label|placeholder }}
Color + {% if cable.color %} +   + {% else %} + + {% endif %} +
Length + {% if cable.length %} + {{ cable.length }} {{ cable.get_length_unit_display }} + {% else %} + + {% endif %} +
+ + +
+
+
+ Termination A +
+ {% include 'dcim/inc/cable_termination.html' with termination=cable.termination_a %} +
+
+
+ Termination B +
+ {% include 'dcim/inc/cable_termination.html' with termination=cable.termination_b %} +
+
+ +{% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html new file mode 100644 index 000000000..4b431ed16 --- /dev/null +++ b/netbox/templates/dcim/cable_connect.html @@ -0,0 +1,158 @@ +{% extends '_base.html' %} +{% load static %} +{% load helpers %} +{% load form_helpers %} + +{% block content %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + {% if form.non_field_errors %} +
+
+
+
Errors
+
+ {{ form.non_field_errors }} +
+
+
+
+ {% endif %} + {% with termination_a=form.instance.termination_a %} +

{% block title %}Connect {{ termination_a.device }} {{ termination_a }}{% endblock %}

+
+
+
+
+ A Side +
+
+ {% if termination_a.device %} + {# Device component #} +
+ +
+

{{ termination_a.device.site }}

+
+
+
+ +
+

{{ termination_a.device.rack|default:"None" }}

+
+
+
+ +
+

{{ termination_a.device }}

+
+
+
+ +
+

{{ termination_a|model_name|capfirst }}

+
+
+
+ +
+

{{ termination_a }}

+
+
+ {% else %} + {# Circuit termination #} +
+ +
+

{{ termination_a.site }}

+
+
+
+ +
+

{{ termination_a.circuit.provider }}

+
+
+
+ +
+

{{ termination_a.circuit.cid }}

+
+
+
+ +
+

{{ termination_a.term_side }}

+
+
+ {% endif %} +
+
+
+
+ +
+
+
+
+ B Side +
+
+ +
+ +
+ {% render_field form.termination_b_site %} + {% render_field form.termination_b_rack %} + {% render_field form.termination_b_device %} +
+
+ {% render_field form.termination_b_type %} + {% render_field form.termination_b_id %} +
+
+
+
+
+
+
+
Cable
+
+ {% render_field form.status %} + {% render_field form.type %} + {% render_field form.label %} + {% render_field form.color %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+
+
+
+
+
+ + Cancel +
+
+ {% endwith %} +
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html new file mode 100644 index 000000000..17403a07d --- /dev/null +++ b/netbox/templates/dcim/cable_edit.html @@ -0,0 +1,23 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Cable
+
+ {% render_field form.type %} + {% render_field form.status %} + {% render_field form.label %} + {% render_field form.color %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/cable_list.html b/netbox/templates/dcim/cable_list.html new file mode 100644 index 000000000..07336e78c --- /dev/null +++ b/netbox/templates/dcim/cable_list.html @@ -0,0 +1,20 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.dcim.add_cable %} + {% import_button 'dcim:cable_import' %} + {% endif %} + {% export_button content_type %} +
+

{% block title %}Cables{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html new file mode 100644 index 000000000..fc645e644 --- /dev/null +++ b/netbox/templates/dcim/cable_trace.html @@ -0,0 +1,48 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block header %} +

{% block title %}Cable Trace for {{ obj }}{% endblock %}

+{% endblock %} + +{% block content %} +
+
+

Near End

+
+
+

Far End

+
+
+ {% for near_end, cable, far_end in trace %} +
+
+

{{ forloop.counter }}

+
+
+ {% include 'dcim/inc/cable_trace_end.html' with end=near_end %} +
+
+ {% if cable %} +

+ + {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} + +

+ {{ cable.get_status_display }}
+ {{ cable.get_type_display|default:"" }} + {% if cable.length %}- {{ cable.length }}{{ cable.get_length_unit_display }}{% endif %} +   + {% else %} +

No Cable

+ {% endif %} +
+
+ {% if far_end %} + {% include 'dcim/inc/cable_trace_end.html' with end=far_end %} + {% endif %} +
+
+ {% if not forloop.last %}
{% endif %} + {% endfor %} +{% endblock %} diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/console_connections_list.html index 89eb0822d..cf47d426c 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/console_connections_list.html @@ -3,9 +3,6 @@ {% block content %}
- {% if perms.dcim.change_consoleport %} - {% import_button 'dcim:console_connections_import' %} - {% endif %} {% export_button content_type %}

{% block title %}Console Connections{% endblock %}

diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html deleted file mode 100644 index 679540960..000000000 --- a/netbox/templates/dcim/consoleport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
{% block title %}Connect {{ consoleport.device }} {{ consoleport }}{% endblock %}
-
- -
- -
- {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.console_server %} -
-
- {% render_field form.cs_port %} - {% render_field form.connection_status %} -
-
-
-
- - Cancel -
-
-
-
-
-{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/consoleport_disconnect.html b/netbox/templates/dcim/consoleport_disconnect.html deleted file mode 100644 index dfd5cf2e7..000000000 --- a/netbox/templates/dcim/consoleport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect console port {{ consoleport }}?{% endblock %} - -{% block message %} -

Are you sure you want to disconnect this console port from {{ consoleport.cs_port.device }} {{ consoleport.cs_port }}?

-{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html deleted file mode 100644 index 989104329..000000000 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
{% block title %}Connect {{ consoleserverport.device }} {{ consoleserverport }}{% endblock %}
-
- -
- -
- {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.device %} -
-
- {% render_field form.port %} - {% render_field form.connection_status %} -
-
-
-
- - Cancel -
-
-
-
-
-{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_disconnect.html b/netbox/templates/dcim/consoleserverport_disconnect.html deleted file mode 100644 index 5c0594464..000000000 --- a/netbox/templates/dcim/consoleserverport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect {{ consoleserverport.device }} {{ consoleserverport }}?{% endblock %} - -{% block message %} -

Are you sure you want to disconnect {{ consoleserverport.connected_console.device }} {{ consoleserverport.connected_console }} from this port?

-{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index faa946f2e..860a3aa44 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load helpers %} {% block title %}{{ device }}{% endblock %} @@ -35,6 +35,21 @@
{% if perms.dcim.change_device %} +
+ + +
Edit this device @@ -127,7 +142,7 @@ {% elif device.rack and device.device_type.u_height %} Not racked {% else %} - N/A + {% endif %} @@ -153,23 +168,11 @@ Serial Number - - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A - {% endif %} - + {{ device.serial|placeholder }} Asset Tag - - {% if device.asset_tag %} - {{ device.asset_tag }} - {% else %} - N/A - {% endif %} - + {{ device.asset_tag|placeholder }}
@@ -251,7 +254,7 @@ (NAT: {{ device.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -266,7 +269,7 @@ (NAT: {{ device.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -300,55 +303,35 @@
-
-
- Console / Power -
- - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% empty %} - {% if device.device_type.console_port_templates.exists %} - - - - {% endif %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% empty %} - {% if device.device_type.power_port_templates.exists %} - - - - {% endif %} - {% endfor %} -
- No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
- No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} -
- {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} -
+ {% endif %} {% if request.user.is_authenticated %}
@@ -501,7 +484,7 @@ {% endif %} {% endif %} - {% if interfaces or device.device_type.is_network_device %} + {% if interfaces %} {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
{% csrf_token %} @@ -527,6 +510,7 @@ Description MTU Mode + Cable Connection @@ -534,10 +518,6 @@ {% for iface in interfaces %} {% include 'dcim/inc/interface.html' %} - {% empty %} - - — No interfaces defined — - {% endfor %} @@ -550,8 +530,8 @@ Edit {% endif %} - {% if interfaces and perms.dcim.delete_interfaceconnection %} - {% endif %} @@ -574,7 +554,7 @@
{% endif %} {% endif %} - {% if cs_ports or device.device_type.is_console_server %} + {% if consoleserverports %} {% if perms.dcim.delete_consoleserverport %}
{% csrf_token %} @@ -590,30 +570,27 @@ {% endif %} Name + Cable Connection - {% for csp in cs_ports %} + {% for csp in consoleserverports %} {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - — No console server ports defined — - {% endfor %}
-{% include 'inc/graphs_modal.html' %} +{% include 'inc/modal.html' with modal_name='graphs' %} {% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} -{% endblock %} diff --git a/netbox/templates/dcim/power_connections_list.html b/netbox/templates/dcim/power_connections_list.html index 4e351eb6a..a41d571fb 100644 --- a/netbox/templates/dcim/power_connections_list.html +++ b/netbox/templates/dcim/power_connections_list.html @@ -3,9 +3,6 @@ {% block content %}
- {% if perms.dcim.change_powerport %} - {% import_button 'dcim:power_connections_import' %} - {% endif %} {% export_button content_type %}

{% block title %}Power Connections{% endblock %}

diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html deleted file mode 100644 index 6c7cef449..000000000 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
{% block title %}Connect {{ poweroutlet.device }} {{ poweroutlet }}{% endblock %}
-
- -
- -
- {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.device %} -
-
- {% render_field form.port %} - {% render_field form.connection_status %} -
-
-
-
- - Cancel -
-
-
-
-
-{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_disconnect.html b/netbox/templates/dcim/poweroutlet_disconnect.html deleted file mode 100644 index 81372033b..000000000 --- a/netbox/templates/dcim/poweroutlet_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect {{ poweroutlet.device }} {{ poweroutlet }}?{% endblock %} - -{% block message %} -

Are you sure you want to disconnect {{ poweroutlet.connected_port.device }} {{ poweroutlet.connected_port }} from this port?

-{% endblock %} diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html deleted file mode 100644 index 1ffa6de28..000000000 --- a/netbox/templates/dcim/powerport_connect.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends '_base.html' %} -{% load static from staticfiles %} -{% load form_helpers %} - -{% block content %} -
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} -
-
- {% endif %} -
-
{% block title %}Connect {{ powerport.device }} {{ powerport }}{% endblock %}
-
- -
- -
- {% render_field form.site %} - {% render_field form.rack %} - {% render_field form.pdu %} -
-
- {% render_field form.power_outlet %} - {% render_field form.connection_status %} -
-
-
-
- - Cancel -
-
-
-
-
-{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/dcim/powerport_disconnect.html b/netbox/templates/dcim/powerport_disconnect.html deleted file mode 100644 index f98694d9f..000000000 --- a/netbox/templates/dcim/powerport_disconnect.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Disconnect power port {{ powerport }}?{% endblock %} - -{% block message %} -

Are you sure you want to disconnect this power port from {{ powerport.power_outlet.device }} {{ powerport.power_outlet }}?

-{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index ebe9a8870..1d92aac22 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -83,13 +83,7 @@ Facility ID - - {% if rack.facility_id %} - {{ rack.facility_id }} - {% else %} - N/A - {% endif %} - + {{ rack.facility_id|placeholder }} Tenant @@ -105,6 +99,12 @@ {% endif %} + + Status + + {{ rack.get_status_display }} + + Role @@ -117,14 +117,12 @@ Serial Number - - {% if rack.serial %} - {{ rack.serial }} - {% else %} - N/A - {% endif %} - + {{ rack.serial|placeholder }} + + Asset Tag + {{ rack.asset_tag|placeholder }} + Devices @@ -156,6 +154,26 @@ Height {{ rack.u_height }}U ({% if rack.desc_units %}descending{% else %}ascending{% endif %}) + + Outer Width + + {% if rack.outer_width %} + {{ rack.outer_width }} {{ rack.get_outer_unit_display }} + {% else %} + + {% endif %} + + + + Outer Depth + + {% if rack.outer_depth %} + {{ rack.outer_depth }} {{ rack.get_outer_unit_display }} + {% else %} + + {% endif %} + +
{% include 'inc/custom_fields_panel.html' with obj=rack %} @@ -195,7 +213,7 @@ {% if device.parent_bay %} {{ device.parent_bay }} {% else %} - N/A + {% endif %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index d500a1954..cd1192c19 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -9,8 +9,10 @@ {% render_field form.name %} {% render_field form.facility_id %} {% render_field form.group %} + {% render_field form.status %} {% render_field form.role %} {% render_field form.serial %} + {% render_field form.asset_tag %}
@@ -26,6 +28,18 @@ {% render_field form.type %} {% render_field form.width %} {% render_field form.u_height %} +
+ +
+ {{ form.outer_width }} +
+
+ {{ form.outer_depth }} +
+
+ {{ form.outer_unit }} +
+
{% render_field form.desc_units %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index f592434c4..0407b67f6 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load tz %} {% load helpers %} @@ -106,23 +106,11 @@ Facility - - {% if site.facility %} - {{ site.facility }} - {% else %} - N/A - {% endif %} - + {{ site.facility|placeholder }} AS Number - - {% if site.asn %} - {{ site.asn }} - {% else %} - N/A - {% endif %} - + {{ site.asn|placeholder }} Time Zone @@ -131,19 +119,13 @@ {{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})
Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %} {% else %} - N/A + {% endif %} Description - - {% if site.description %} - {{ site.description }} - {% else %} - N/A - {% endif %} - + {{ site.description|placeholder }} @@ -163,19 +145,13 @@ {{ site.physical_address|linebreaksbr }} {% else %} - N/A + {% endif %} Shipping Address - - {% if site.shipping_address %} - {{ site.shipping_address|linebreaksbr }} - {% else %} - N/A - {% endif %} - + {{ site.shipping_address|linebreaksbr|placeholder }} GPS Coordinates @@ -188,19 +164,13 @@ {{ site.latitude }}, {{ site.longitude }} {% else %} - N/A + {% endif %} Contact Name - - {% if site.contact_name %} - {{ site.contact_name }} - {% else %} - N/A - {% endif %} - + {{ site.contact_name|placeholder }} Contact Phone @@ -208,7 +178,7 @@ {% if site.contact_phone %} {{ site.contact_phone }} {% else %} - N/A + {% endif %} @@ -218,7 +188,7 @@ {% if site.contact_email %} {{ site.contact_email }} {% else %} - N/A + {% endif %} @@ -330,7 +300,7 @@ -{% include 'inc/graphs_modal.html' %} +{% include 'inc/modal.html' with modal_name='graphs' %} {% endblock %} {% block javascript %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 097cb487f..2665473fc 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% load form_helpers %} {% block content %} @@ -52,16 +53,10 @@ {% if device.rack %} {{ device.rack }} / {{ device.position }} {% else %} - N/A - {% endif %} - - - {% if device.serial %} - {{ device.serial }} - {% else %} - N/A + {% endif %} + {{ device.serial|placeholder }} {{ form.vc_position }} {% if form.vc_position.errors %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index c987daf33..6c14b7b16 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -55,13 +55,7 @@ Description - - {% if configcontext.description %} - {{ configcontext.description }} - {% else %} - N/A - {% endif %} - + {{ configcontext.description|placeholder }} Active diff --git a/netbox/templates/home.html b/netbox/templates/home.html index d6af56458..76d90bad7 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -39,6 +39,8 @@

Connections

+ {{ stats.cable_count }} +

Cables

{{ stats.interface_connections_count }}

Interfaces

{{ stats.console_connections_count }} diff --git a/netbox/templates/inc/ajax_loader.html b/netbox/templates/inc/ajax_loader.html index b5b3ee2c1..f6982bd65 100644 --- a/netbox/templates/inc/ajax_loader.html +++ b/netbox/templates/inc/ajax_loader.html @@ -1,4 +1,4 @@ -{% load staticfiles %} +{% load static %}
diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index e469048af..52d9c2d6e 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -20,7 +20,7 @@ {% elif field.required %} Not defined {% else %} - N/A + {% endif %} diff --git a/netbox/templates/inc/graphs_modal.html b/netbox/templates/inc/modal.html similarity index 68% rename from netbox/templates/inc/graphs_modal.html rename to netbox/templates/inc/modal.html index 29eaf18bf..b70b9115f 100644 --- a/netbox/templates/inc/graphs_modal.html +++ b/netbox/templates/inc/modal.html @@ -1,9 +1,9 @@ - diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 940a87157..6d3119d5b 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load helpers %} {% load secret_helpers %} @@ -59,13 +59,7 @@ Name - - {% if secret.name %} - {{ secret.name }} - {% else %} - N/A - {% endif %} - + {{ secret.name|placeholder }}
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 2d2fc4644..be196aa57 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index a460f80e8..169f16b11 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -1,5 +1,5 @@ {% extends 'utilities/obj_import.html' %} -{% load static from staticfiles %} +{% load static %} {% block content %} {{ block.super }} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 6068a7102..91d3ce986 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -71,13 +71,7 @@ Description - - {% if tenant.description %} - {{ tenant.description }} - {% else %} - N/A - {% endif %} - + {{ tenant.description|placeholder }} diff --git a/netbox/templates/tenancy/tenant_edit.html b/netbox/templates/tenancy/tenant_edit.html index 31bc73f3e..6f58bb450 100644 --- a/netbox/templates/tenancy/tenant_edit.html +++ b/netbox/templates/tenancy/tenant_edit.html @@ -1,5 +1,5 @@ {% extends 'utilities/obj_edit.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block form %} diff --git a/netbox/templates/users/_user.html b/netbox/templates/users/_user.html index 1a4b5c6c5..9f71b9633 100644 --- a/netbox/templates/users/_user.html +++ b/netbox/templates/users/_user.html @@ -21,9 +21,6 @@ User Key - - Recent Activity -
diff --git a/netbox/templates/users/recent_activity.html b/netbox/templates/users/recent_activity.html deleted file mode 100644 index 92933d78b..000000000 --- a/netbox/templates/users/recent_activity.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'users/_user.html' %} - -{% block title %}Recent Activity{% endblock %} - -{% block usercontent %} - - - - - - - - - {% for action in recent_activity %} - - - - - {% endfor %} - -
TimeAction
{{ action.time|date:'SHORT_DATETIME_FORMAT' }}{{ action.icon }} {{ action.message|safe }}
-{% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index c590f4423..40c3715b0 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -1,5 +1,5 @@ {% extends 'users/_user.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block title %}User Key{% endblock %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 0a792e0c4..cdb946c57 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -1,5 +1,5 @@ {% extends '_base.html' %} -{% load static from staticfiles %} +{% load static %} {% load form_helpers %} {% block content %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 9f8ec8308..1556c5af0 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -116,7 +116,7 @@ (NAT: {{ virtualmachine.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -131,7 +131,7 @@ (NAT: {{ virtualmachine.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - N/A + {% endif %} @@ -181,13 +181,7 @@ - + @@ -195,7 +189,7 @@ {% if virtualmachine.memory %} {{ virtualmachine.memory }} MB {% else %} - N/A + {% endif %} @@ -205,7 +199,7 @@ {% if virtualmachine.disk %} {{ virtualmachine.disk }} GB {% else %} - N/A + {% endif %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py new file mode 100644 index 000000000..d26ac4675 --- /dev/null +++ b/netbox/tenancy/api/nested_serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from tenancy.models import Tenant, TenantGroup +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedTenantGroupSerializer', + 'NestedTenantSerializer', +] + + +# +# Tenants +# + +class NestedTenantGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') + + class Meta: + model = TenantGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedTenantSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') + + class Meta: + model = Tenant + fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 592e35a6e..80f3b948d 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,15 +1,13 @@ -from __future__ import unicode_literals - -from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup -from utilities.api import ValidatedModelSerializer, WritableNestedSerializer +from utilities.api import ValidatedModelSerializer +from .nested_serializers import * # -# Tenant groups +# Tenants # class TenantGroupSerializer(ValidatedModelSerializer): @@ -19,18 +17,6 @@ class TenantGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedTenantGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') - - class Meta: - model = TenantGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Tenants -# - class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) tags = TagListSerializerField(required=False) @@ -41,11 +27,3 @@ class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - - -class NestedTenantSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') - - class Meta: - model = Tenant - fields = ['id', 'url', 'name', 'slug'] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index a36a1ec3d..3da0e0f82 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = TenancyRootView # Field choices -router.register(r'_choices', views.TenancyFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.TenancyFieldChoicesViewSet, basename='field-choice') # Tenants router.register(r'tenant-groups', views.TenantGroupViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 48cd76163..af3e318fc 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from extras.api.views import CustomFieldModelViewSet from tenancy import filters from tenancy.models import Tenant, TenantGroup @@ -22,7 +20,7 @@ class TenancyFieldChoicesViewSet(FieldChoicesViewSet): class TenantGroupViewSet(ModelViewSet): queryset = TenantGroup.objects.all() serializer_class = serializers.TenantGroupSerializer - filter_class = filters.TenantGroupFilter + filterset_class = filters.TenantGroupFilter # @@ -32,4 +30,4 @@ class TenantGroupViewSet(ModelViewSet): class TenantViewSet(CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group').prefetch_related('tags') serializer_class = serializers.TenantSerializer - filter_class = filters.TenantFilter + filterset_class = filters.TenantFilter diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py index df2cd2fbb..53cb9a056 100644 --- a/netbox/tenancy/apps.py +++ b/netbox/tenancy/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 4ff620d39..5b3ec30d4 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.db.models import Q @@ -16,7 +14,10 @@ class TenantGroupFilter(django_filters.FilterSet): class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -26,7 +27,7 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=TenantGroup.objects.all(), to_field_name='slug', label='Group (slug)', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b90934923..4c57453ca 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.db.models import Count from taggit.forms import TagField @@ -20,7 +18,9 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class TenantGroupCSVForm(forms.ModelForm): @@ -41,11 +41,15 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldForm): slug = SlugField() comments = CommentField() - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments', 'tags'] + fields = [ + 'name', 'slug', 'group', 'description', 'comments', 'tags', + ] class TenantCSVForm(forms.ModelForm): @@ -70,18 +74,31 @@ class TenantCSVForm(forms.ModelForm): class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) - group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Tenant.objects.all(), + widget=forms.MultipleHiddenInput() + ) + group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) class Meta: - nullable_fields = ['group'] + nullable_fields = [ + 'group', + ] class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) group = FilterChoiceField( - queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), + queryset=TenantGroup.objects.annotate( + filter_count=Count('tenants') + ), to_field_name='slug', null_label='-- None --' ) @@ -96,7 +113,10 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): queryset=TenantGroup.objects.all(), required=False, widget=forms.Select( - attrs={'filter-for': 'tenant', 'nullable': 'true'} + attrs={ + 'filter-for': 'tenant', + 'nullable': 'true', + } ) ) tenant = ChainedModelChoiceField( @@ -119,4 +139,4 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): initial['tenant_group'] = instance.tenant.group kwargs['initial'] = initial - super(TenancyForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) diff --git a/netbox/tenancy/migrations/0001_initial.py b/netbox/tenancy/migrations/0001_initial.py index ed2f800ef..fcad19413 100644 --- a/netbox/tenancy/migrations/0001_initial.py +++ b/netbox/tenancy/migrations/0001_initial.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-07-26 21:58 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional.py b/netbox/tenancy/migrations/0002_tenant_group_optional.py index 95b1138ac..3d91b76ec 100644 --- a/netbox/tenancy/migrations/0002_tenant_group_optional.py +++ b/netbox/tenancy/migrations/0002_tenant_group_optional.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.9.8 on 2016-08-02 19:54 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py index d4258f4dc..77dc55975 100644 --- a/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py +++ b/netbox/tenancy/migrations/0002_tenant_group_optional_squashed_0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:12 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/tenancy/migrations/0003_unicode_literals.py b/netbox/tenancy/migrations/0003_unicode_literals.py index ed547c510..24cc7f969 100644 --- a/netbox/tenancy/migrations/0003_unicode_literals.py +++ b/netbox/tenancy/migrations/0003_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/tenancy/migrations/0004_tags.py b/netbox/tenancy/migrations/0004_tags.py index 5cb9398b5..dbea49cd0 100644 --- a/netbox/tenancy/migrations/0004_tags.py +++ b/netbox/tenancy/migrations/0004_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/tenancy/migrations/0005_change_logging.py b/netbox/tenancy/migrations/0005_change_logging.py index 7712e9d02..eb0979366 100644 --- a/netbox/tenancy/migrations/0005_change_logging.py +++ b/netbox/tenancy/migrations/0005_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:14 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 5a22143d3..045679b90 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,16 +1,12 @@ -from __future__ import unicode_literals - from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from extras.models import CustomFieldModel from utilities.models import ChangeLoggedModel -@python_2_unicode_compatible class TenantGroup(ChangeLoggedModel): """ An arbitrary collection of Tenants. @@ -41,7 +37,6 @@ class TenantGroup(ChangeLoggedModel): ) -@python_2_unicode_compatible class Tenant(ChangeLoggedModel, CustomFieldModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2e763591a..91122df7a 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from utilities.tables import BaseTable, ToggleColumn diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 78b907d20..69db73ac6 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from rest_framework import status @@ -11,7 +9,7 @@ class TenantGroupTest(APITestCase): def setUp(self): - super(TenantGroupTest, self).setUp() + super().setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') @@ -112,7 +110,7 @@ class TenantTest(APITestCase): def setUp(self): - super(TenantTest, self).setUp() + super().setUp() self.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1') self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2') diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 2da03b7f5..19522e6c7 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index fdb453665..97334c9f0 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,7 +1,5 @@ -from __future__ import unicode_literals - from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db.models import Count, Q +from django.db.models import Count from django.shortcuts import get_object_or_404, render from django.views.generic import View diff --git a/netbox/users/admin.py b/netbox/users/admin.py index ba7a0f912..a0c368916 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.contrib import admin diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py new file mode 100644 index 000000000..d1b649713 --- /dev/null +++ b/netbox/users/api/nested_serializers.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import User + +from utilities.api import WritableNestedSerializer + +_all_ = [ + 'NestedUserSerializer', +] + + +# +# Users +# + +class NestedUserSerializer(WritableNestedSerializer): + + class Meta: + model = User + fields = ['id', 'username'] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 861bdade9..86d350e69 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,12 +1,4 @@ -from __future__ import unicode_literals - -from django.contrib.auth.models import User - -from utilities.api import WritableNestedSerializer +from .nested_serializers import * -class NestedUserSerializer(WritableNestedSerializer): - - class Meta: - model = User - fields = ['id', 'username'] +# Placeholder for future serializers diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d25e128e6..641a1f3e8 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm @@ -10,7 +8,7 @@ from .models import Token class LoginForm(BootstrapMixin, AuthenticationForm): def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['username'].widget.attrs['placeholder'] = '' self.fields['password'].widget.attrs['placeholder'] = '' @@ -21,11 +19,16 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): class TokenForm(BootstrapMixin, forms.ModelForm): - key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.") + key = forms.CharField( + required=False, + help_text="If no key is provided, one will be generated automatically." + ) class Meta: model = Token - fields = ['key', 'write_enabled', 'expires', 'description'] + fields = [ + 'key', 'write_enabled', 'expires', 'description', + ] help_texts = { 'expires': 'YYYY-MM-DD [HH:MM:SS]' } diff --git a/netbox/users/migrations/0001_api_tokens.py b/netbox/users/migrations/0001_api_tokens.py index d766b2ef0..3e2ea274e 100644 --- a/netbox/users/migrations/0001_api_tokens.py +++ b/netbox/users/migrations/0001_api_tokens.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.10.6 on 2017-03-08 15:32 -from __future__ import unicode_literals - from django.conf import settings import django.core.validators from django.db import migrations, models diff --git a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py index 54a6078a0..1c82a092d 100644 --- a/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py +++ b/netbox/users/migrations/0001_api_tokens_squashed_0002_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-08-01 17:43 -from __future__ import unicode_literals - from django.conf import settings import django.core.validators from django.db import migrations, models diff --git a/netbox/users/migrations/0002_unicode_literals.py b/netbox/users/migrations/0002_unicode_literals.py index 8a7f96bbd..d0cf75fd8 100644 --- a/netbox/users/migrations/0002_unicode_literals.py +++ b/netbox/users/migrations/0002_unicode_literals.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11 on 2017-05-24 15:34 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/users/models.py b/netbox/users/models.py index 15f4f46f4..2956a9778 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import binascii import os @@ -7,10 +5,8 @@ from django.contrib.auth.models import User from django.core.validators import MinLengthValidator from django.db import models from django.utils import timezone -from django.utils.encoding import python_2_unicode_compatible -@python_2_unicode_compatible class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -52,7 +48,7 @@ class Token(models.Model): def save(self, *args, **kwargs): if not self.key: self.key = self.generate_key() - return super(Token, self).save(*args, **kwargs) + return super().save(*args, **kwargs) def generate_key(self): # Generate a random 160-bit key expressed in hexadecimal. diff --git a/netbox/users/urls.py b/netbox/users/urls.py index aad89e104..a45f859e7 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from . import views @@ -16,6 +14,5 @@ urlpatterns = [ url(r'^user-key/$', views.UserKeyView.as_view(), name='userkey'), url(r'^user-key/edit/$', views.UserKeyEditView.as_view(), name='userkey_edit'), url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'), - url(r'^recent-activity/$', views.RecentActivityView.as_view(), name='recent_activity'), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index bc8263202..171d444b9 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.decorators import login_required @@ -38,7 +36,7 @@ class LoginView(View): # Determine where to direct user after successful login redirect_to = request.POST.get('next', '') - if not is_safe_url(url=redirect_to, host=request.get_host()): + if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): redirect_to = reverse('home') # Authenticate user @@ -134,7 +132,7 @@ class UserKeyEditView(View): except UserKey.DoesNotExist: self.userkey = UserKey(user=request.user) - return super(UserKeyEditView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get(self, request): form = UserKeyForm(instance=self.userkey) @@ -198,18 +196,6 @@ class SessionKeyDeleteView(LoginRequiredMixin, View): }) -@method_decorator(login_required, name='dispatch') -class RecentActivityView(View): - template_name = 'users/recent_activity.html' - - def get(self, request): - - return render(request, self.template_name, { - 'recent_activity': request.user.actions.all()[:50], - 'active_tab': 'recent_activity', - }) - - # # API tokens # diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 9b9dabef5..c24fd1a16 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - from collections import OrderedDict -import pytz +import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist @@ -68,18 +66,29 @@ class ChoiceField(Field): self._choices[k2] = v2 else: self._choices[k] = v - super(ChoiceField, self).__init__(**kwargs) + super().__init__(**kwargs) def to_representation(self, obj): - return {'value': obj, 'label': self._choices[obj]} + if obj is '': + return None + data = OrderedDict([ + ('value', obj), + ('label', self._choices[obj]) + ]) + return data def to_internal_value(self, data): - # Hotwiring boolean values if hasattr(data, 'lower'): + # Hotwiring boolean values from string if data.lower() == 'true': return True if data.lower() == 'false': return False + # Check for string representation of an integer (e.g. "123") + try: + data = int(data) + except ValueError: + pass return data @@ -121,7 +130,7 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField): def __init__(self, serializer, **kwargs): self.serializer = serializer self.pk_field = kwargs.pop('pk_field', None) - super(SerializedPKRelatedField, self).__init__(**kwargs) + super().__init__(**kwargs) def to_representation(self, value): return self.serializer(value, context={'request': self.context['request']}).data @@ -165,6 +174,13 @@ class WritableNestedSerializer(ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ + def run_validators(self, value): + # DRF v3.8.2: Skip running validators on the data, since we only accept an integer PK instead of a dict. For + # more context, see: + # https://github.com/encode/django-rest-framework/pull/5922/commits/2227bc47f8b287b66775948ffb60b2d9378ac84f + # https://github.com/encode/django-rest-framework/issues/6053 + return + def to_internal_value(self, data): if data is None: return None @@ -190,7 +206,7 @@ class ModelViewSet(_ModelViewSet): if isinstance(kwargs.get('data', {}), list): kwargs['many'] = True - return super(ModelViewSet, self).get_serializer(*args, **kwargs) + return super().get_serializer(*args, **kwargs) def get_serializer_class(self): @@ -214,7 +230,7 @@ class FieldChoicesViewSet(ViewSet): fields = [] def __init__(self, *args, **kwargs): - super(FieldChoicesViewSet, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Compile a dict of all fields in this view self._fields = OrderedDict() diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 1cb3999ef..64c2fab85 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -1,7 +1,26 @@ -from utilities.forms import ChainedModelMultipleChoiceField - - -# Fields which are used on ManyToMany relationships -M2M_FIELD_TYPES = [ - ChainedModelMultipleChoiceField, -] +COLOR_CHOICES = ( + ('aa1409', 'Dark red'), + ('f44336', 'Red'), + ('e91e63', 'Pink'), + ('ff66ff', 'Fuschia'), + ('9c27b0', 'Purple'), + ('673ab7', 'Dark purple'), + ('3f51b5', 'Indigo'), + ('2196f3', 'Blue'), + ('03a9f4', 'Light blue'), + ('00bcd4', 'Cyan'), + ('009688', 'Teal'), + ('2f6a31', 'Dark green'), + ('4caf50', 'Green'), + ('8bc34a', 'Light green'), + ('cddc39', 'Lime'), + ('ffeb3b', 'Yellow'), + ('ffc107', 'Amber'), + ('ff9800', 'Orange'), + ('ff5722', 'Dark orange'), + ('795548', 'Brown'), + ('c0c0c0', 'Light grey'), + ('9e9e9e', 'Grey'), + ('607d8b', 'Dark grey'), + ('111111', 'Black'), +) diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py index dab35e982..06c5c8784 100644 --- a/netbox/utilities/context_processors.py +++ b/netbox/utilities/context_processors.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings as django_settings diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 3b7eb7a5b..da8510950 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.utils.html import escape from django.utils.safestring import mark_safe diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 34f59fe16..104902b1f 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -1,11 +1,8 @@ -from __future__ import unicode_literals - from django.core.validators import RegexValidator from django.db import models from .forms import ColorSelect - ColorValidator = RegexValidator( regex='^[0-9a-f]{6}$', message='Enter a valid hexadecimal RGB color code.', @@ -31,8 +28,8 @@ class ColorField(models.CharField): def __init__(self, *args, **kwargs): kwargs['max_length'] = 6 - super(ColorField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def formfield(self, **kwargs): kwargs['widget'] = ColorSelect - return super(ColorField, self).formfield(**kwargs) + return super().formfield(**kwargs) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index a7f23d2f6..4da3a9856 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,17 +1,7 @@ -from __future__ import unicode_literals - -import itertools - import django_filters -from django import forms -from django.utils.encoding import force_text from taggit.models import Tag -# -# Filters -# - class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): """ Filters for a set of numeric values. Example: id__in=100,200,300 @@ -27,50 +17,11 @@ class NullableCharFieldFilter(django_filters.CharFilter): def filter(self, qs, value): if value != self.null_value: - return super(NullableCharFieldFilter, self).filter(qs, value) + return super().filter(qs, value) qs = self.get_method(qs)(**{'{}__isnull'.format(self.name): True}) return qs.distinct() if self.distinct else qs -class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): - """ - This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is - used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null - choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar - to defining a MultipleChoiceField with: - - choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()] - - However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating - database migrations. - """ - iterator = forms.models.ModelChoiceIterator - - def __init__(self, null_value=0, null_label='-- None --', *args, **kwargs): - self.null_value = null_value - self.null_label = null_label - super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs) - - def _get_choices(self): - if hasattr(self, '_choices'): - return self._choices - # Prepend the null choice to the queryset iterator - return itertools.chain( - [(self.null_value, self.null_label)], - self.iterator(self), - ) - choices = property(_get_choices, forms.ChoiceField._set_choices) - - def clean(self, value): - # Strip all instances of the null value before cleaning - if value is not None: - stripped_value = [x for x in value if x != force_text(self.null_value)] - else: - stripped_value = value - super(NullableModelMultipleChoiceField, self).clean(stripped_value) - return value - - class TagFilter(django_filters.ModelMultipleChoiceFilter): """ Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=foo&tag=bar), the queryset is filtered @@ -78,9 +29,9 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): """ def __init__(self, *args, **kwargs): - kwargs.setdefault('name', 'tags__slug') + kwargs.setdefault('field_name', 'tags__slug') kwargs.setdefault('to_field_name', 'slug') kwargs.setdefault('conjoined', True) kwargs.setdefault('queryset', Tag.objects.all()) - super(TagFilter, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f54b418ca..46dbcc789 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,10 +1,7 @@ -from __future__ import unicode_literals - import csv -from io import StringIO import json import re -import sys +from io import StringIO from django import forms from django.conf import settings @@ -13,38 +10,18 @@ from django.db.models import Count from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField +from .constants import * from .validators import EnhancedURLValidator -COLOR_CHOICES = ( - ('aa1409', 'Dark red'), - ('f44336', 'Red'), - ('e91e63', 'Pink'), - ('ff66ff', 'Fuschia'), - ('9c27b0', 'Purple'), - ('673ab7', 'Dark purple'), - ('3f51b5', 'Indigo'), - ('2196f3', 'Blue'), - ('03a9f4', 'Light blue'), - ('00bcd4', 'Cyan'), - ('009688', 'Teal'), - ('2f6a31', 'Dark green'), - ('4caf50', 'Green'), - ('8bc34a', 'Light green'), - ('cddc39', 'Lime'), - ('ffeb3b', 'Yellow'), - ('ffc107', 'Amber'), - ('ff9800', 'Orange'), - ('ff5722', 'Dark orange'), - ('795548', 'Brown'), - ('c0c0c0', 'Light grey'), - ('9e9e9e', 'Grey'), - ('607d8b', 'Dark grey'), - ('111111', 'Black'), -) NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' +BOOLEAN_WITH_BLANK_CHOICES = ( + ('', '---------'), + ('True', 'Yes'), + ('False', 'No'), +) def parse_numeric_range(string, base=10): @@ -65,22 +42,6 @@ def parse_numeric_range(string, base=10): return list(set(values)) -def expand_numeric_pattern(string): - """ - Expand a numeric pattern into a list of strings. Examples: - 'ge-0/0/[0-3,5]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3', 'ge-0/0/5'] - 'xe-0/[0,2-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] - """ - lead, pattern, remnant = re.split(NUMERIC_EXPANSION_PATTERN, string, maxsplit=1) - parsed_range = parse_numeric_range(pattern) - for i in parsed_range: - if re.search(NUMERIC_EXPANSION_PATTERN, remnant): - for string in expand_numeric_pattern(remnant): - yield "{}{}{}".format(lead, i, string) - else: - yield "{}{}{}".format(lead, i, remnant) - - def parse_alphanumeric_range(string): """ Expand an alphanumeric range (continuous or not) into a list. @@ -123,7 +84,7 @@ def expand_alphanumeric_pattern(string): def expand_ipaddress_pattern(string, family): """ Expand an IP address pattern into a list of strings. Examples: - '192.0.2.[1,2,100-250,254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24', '192.0.2.254/24'] + '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] """ if family not in [4, 6]: @@ -151,9 +112,41 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) -def utf8_encoder(data): - for line in data: - yield line.encode('utf-8') +def unpack_grouped_choices(choices): + """ + Unpack a grouped choices hierarchy into a flat list of two-tuples. For example: + + choices = ( + ('Foo', ( + (1, 'A'), + (2, 'B') + )), + ('Bar', ( + (3, 'C'), + (4, 'D') + )) + ) + + becomes: + + choices = ( + (1, 'A'), + (2, 'B'), + (3, 'C'), + (4, 'D') + ) + """ + unpacked_choices = [] + for key, value in choices: + if key == 1300: + breakme = True + if isinstance(value, (list, tuple)): + # Entered an optgroup + for optgroup_key, optgroup_value in value: + unpacked_choices.append((optgroup_key, optgroup_value)) + else: + unpacked_choices.append((key, value)) + return unpacked_choices # @@ -174,8 +167,8 @@ class ColorSelect(forms.Select): option_template_name = 'widgets/colorselect_option.html' def __init__(self, *args, **kwargs): - kwargs['choices'] = COLOR_CHOICES - super(ColorSelect, self).__init__(*args, **kwargs) + kwargs['choices'] = add_blank_choice(COLOR_CHOICES) + super().__init__(*args, **kwargs) class BulkEditNullBooleanSelect(forms.NullBooleanSelect): @@ -184,7 +177,7 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect): """ def __init__(self, *args, **kwargs): - super(BulkEditNullBooleanSelect, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Override the built-in choice labels self.choices = ( @@ -209,23 +202,32 @@ class SelectWithPK(forms.Select): option_template_name = 'widgets/select_option_with_pk.html' +class ContentTypeSelect(forms.Select): + """ + Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: + + This attribute can be used to reference the relevant API endpoint for a particular ContentType. + """ + option_template_name = 'widgets/select_contenttype.html' + + class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): """ MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget. """ def __init__(self, *args, **kwargs): self.delimiter = kwargs.pop('delimiter', ',') - super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def optgroups(self, name, value, attrs=None): # Split the delimited string of values into a list if value: value = value[0].split(self.delimiter) - return super(ArrayFieldSelectMultiple, self).optgroups(name, value, attrs) + return super().optgroups(name, value, attrs) def value_from_datadict(self, data, files, name): # Condense the list of selected choices into a delimited string - data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name) + data = super().value_from_datadict(data, files, name) return self.delimiter.join(data) @@ -236,11 +238,23 @@ class APISelect(SelectWithDisabled): :param api_url: API URL :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. + :param url_conditional_append: (Optional) A dict of URL query strings to append to the URL if the + condition is met. The condition is the dict key and is specified in the form `__`. + If the provided field value is selected for the given field, the URL query string will be appended to + the rendered URL. This is useful in cases where a particular field value dictates an additional API filter. """ - def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs): + def __init__( + self, + api_url, + display_field=None, + disabled_indicator=None, + url_conditional_append=None, + *args, + **kwargs + ): - super(APISelect, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.attrs['class'] = 'api-select' self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH @@ -248,6 +262,9 @@ class APISelect(SelectWithDisabled): self.attrs['display-field'] = display_field if disabled_indicator: self.attrs['disabled-indicator'] = disabled_indicator + if url_conditional_append: + for key, value in url_conditional_append.items(): + self.attrs["data-url-conditional-append-{}".format(key)] = value class APISelectMultiple(APISelect): @@ -266,7 +283,7 @@ class Livesearch(forms.TextInput): def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs): - super(Livesearch, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.attrs = { 'data-key': query_key, @@ -294,7 +311,7 @@ class CSVDataField(forms.CharField): self.fields = fields self.required_fields = required_fields - super(CSVDataField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.strip = False if not self.label: @@ -309,12 +326,7 @@ class CSVDataField(forms.CharField): def to_python(self, value): records = [] - - # Python 2 hack for Unicode support in the CSV reader - if sys.version_info[0] < 3: - reader = csv.reader(utf8_encoder(StringIO(value))) - else: - reader = csv.reader(StringIO(value)) + reader = csv.reader(StringIO(value)) # Consume and validate the first line of CSV data as column headers headers = next(reader) @@ -345,12 +357,12 @@ class CSVChoiceField(forms.ChoiceField): """ def __init__(self, choices, *args, **kwargs): - super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs) - self.choices = [(label, label) for value, label in choices] - self.choice_values = {label: value for value, label in choices} + super().__init__(choices=choices, *args, **kwargs) + self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] + self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} def clean(self, value): - value = super(CSVChoiceField, self).clean(value) + value = super().clean(value) if not value: return None if value not in self.choice_values: @@ -364,7 +376,7 @@ class ExpandableNameField(forms.CharField): Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] """ def __init__(self, *args, **kwargs): - super(ExpandableNameField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Alphanumeric ranges are supported for bulk creation.
' \ 'Mixed cases and types within a single range are not supported.
' \ @@ -384,7 +396,7 @@ class ExpandableIPAddressField(forms.CharField): Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] """ def __init__(self, *args, **kwargs): - super(ExpandableIPAddressField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Specify a numeric range to create multiple IPs.
'\ 'Example: 192.0.2.[1,5,100-254]/24' @@ -413,7 +425,7 @@ class CommentField(forms.CharField): required = kwargs.pop('required', False) label = kwargs.pop('label', self.default_label) help_text = kwargs.pop('help_text', self.default_helptext) - super(CommentField, self).__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) class FlexibleModelChoiceField(forms.ModelChoiceField): @@ -453,7 +465,7 @@ class ChainedModelChoiceField(forms.ModelChoiceField): """ def __init__(self, chains=None, *args, **kwargs): self.chains = chains - super(ChainedModelChoiceField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): @@ -462,7 +474,7 @@ class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): """ def __init__(self, chains=None, *args, **kwargs): self.chains = chains - super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class SlugField(forms.SlugField): @@ -472,7 +484,7 @@ class SlugField(forms.SlugField): def __init__(self, slug_source='name', *args, **kwargs): label = kwargs.pop('label', "Slug") help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - super(SlugField, self).__init__(label=label, help_text=help_text, *args, **kwargs) + super().__init__(label=label, help_text=help_text, *args, **kwargs) self.widget.attrs['slug-source'] = slug_source @@ -499,10 +511,10 @@ class FilterChoiceFieldMixin(object): kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) - super(FilterChoiceFieldMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def label_from_instance(self, obj): - label = super(FilterChoiceFieldMixin, self).label_from_instance(obj) + label = super().label_from_instance(obj) if hasattr(obj, 'filter_count'): return '{} ({})'.format(label, obj.filter_count) return label @@ -543,9 +555,9 @@ class AnnotatedMultipleChoiceField(forms.MultipleChoiceField): def __init__(self, choices, annotate, annotate_field, *args, **kwargs): self.annotate = annotate self.annotate_field = annotate_field - self.static_choices = choices + self.static_choices = unpack_grouped_choices(choices) - super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs) + super().__init__(choices=self.annotate_choices, *args, **kwargs) class LaxURLField(forms.URLField): @@ -562,7 +574,7 @@ class JSONField(_JSONField): Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. """ def __init__(self, *args, **kwargs): - super(JSONField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Enter context data in JSON format.' self.widget.attrs['placeholder'] = '' @@ -584,7 +596,7 @@ class BootstrapMixin(forms.BaseForm): Add the base Bootstrap CSS classes to form elements. """ def __init__(self, *args, **kwargs): - super(BootstrapMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) exempt_widgets = [ forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect @@ -605,7 +617,7 @@ class ChainedFieldsMixin(forms.BaseForm): Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. """ def __init__(self, *args, **kwargs): - super(ChainedFieldsMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) for field_name, field in self.fields.items(): @@ -654,7 +666,10 @@ class ComponentForm(BootstrapMixin, forms.Form): """ def __init__(self, parent, *args, **kwargs): self.parent = parent - super(ComponentForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + + def get_iterative_data(self, iteration): + return {} class BulkEditForm(forms.Form): @@ -662,7 +677,7 @@ class BulkEditForm(forms.Form): Base form for editing multiple objects in bulk """ def __init__(self, model, parent_obj=None, *args, **kwargs): - super(BulkEditForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.model = model self.parent_obj = parent_obj self.nullable_fields = [] diff --git a/netbox/utilities/management/__init__.py b/netbox/utilities/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py new file mode 100644 index 000000000..697a3ed9a --- /dev/null +++ b/netbox/utilities/management/commands/__init__.py @@ -0,0 +1,28 @@ +from django.db import models + + +EXEMPT_ATTRS = [ + 'choices', + 'help_text', + 'verbose_name', +] + +_deconstruct = models.Field.deconstruct + + +def custom_deconstruct(field): + """ + Imitate the behavior of the stock deconstruct() method, but ignore the field attributes listed above. + """ + name, path, args, kwargs = _deconstruct(field) + + # Remove any ignored attributes + for attr in EXEMPT_ATTRS: + kwargs.pop(attr, None) + + # A hack to accommodate TimeZoneField, which employs a custom deconstructor to check whether the default choices + # have changed + if hasattr(field, 'CHOICES'): + kwargs['choices'] = field.CHOICES + + return name, path, args, kwargs diff --git a/netbox/utilities/management/commands/makemigrations.py b/netbox/utilities/management/commands/makemigrations.py new file mode 100644 index 000000000..fbcf82eaf --- /dev/null +++ b/netbox/utilities/management/commands/makemigrations.py @@ -0,0 +1,7 @@ +# noinspection PyUnresolvedReferences +from django.core.management.commands.makemigrations import Command +from django.db import models + +from . import custom_deconstruct + +models.Field.deconstruct = custom_deconstruct diff --git a/netbox/utilities/management/commands/migrate.py b/netbox/utilities/management/commands/migrate.py new file mode 100644 index 000000000..2aa51b713 --- /dev/null +++ b/netbox/utilities/management/commands/migrate.py @@ -0,0 +1,7 @@ +# noinspection PyUnresolvedReferences +from django.core.management.commands.migrate import Command +from django.db import models + +from . import custom_deconstruct + +models.Field.deconstruct = custom_deconstruct diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index b112f4fae..724773c46 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -1,28 +1,30 @@ -from __future__ import unicode_literals - from django.db.models import Manager +NAT1 = r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)" +NAT2 = r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')" +NAT3 = r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)" -class NaturalOrderByManager(Manager): + +class NaturalOrderingManager(Manager): """ - Order objects naturally by a designated field. Leading and/or trailing digits of values within this field will be - cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before "Foo10", even though - the digit 1 is normally ordered before the digit 2. + Order objects naturally by a designated field (defaults to 'name'). Leading and/or trailing digits of values within + this field will be cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before + "Foo10", even though the digit 1 is normally ordered before the digit 2. """ - natural_order_field = None + natural_order_field = 'name' def get_queryset(self): - queryset = super(NaturalOrderByManager, self).get_queryset() + queryset = super().get_queryset() db_table = self.model._meta.db_table db_field = self.natural_order_field # Append the three subfields derived from the designated natural ordering field queryset = queryset.extra(select={ - '_nat1': r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)".format(db_table, db_field), - '_nat2': r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')".format(db_table, db_field), - '_nat3': r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)".format(db_table, db_field), + '_nat1': NAT1.format(db_table, db_field), + '_nat2': NAT2.format(db_table, db_field), + '_nat3': NAT3.format(db_table, db_field), }) # Replace any instance of the designated natural ordering field with its three subfields diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index dafafde24..4e321ab19 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -1,7 +1,3 @@ -from __future__ import unicode_literals - -import sys - from django.conf import settings from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect @@ -72,11 +68,7 @@ class ExceptionHandlingMiddleware(object): custom_template = 'exceptions/programming_error.html' elif isinstance(exception, ImportError): custom_template = 'exceptions/import_error.html' - elif ( - sys.version_info[0] >= 3 and isinstance(exception, PermissionError) - ) or ( - isinstance(exception, OSError) and exception.errno == 13 - ): + elif isinstance(exception, PermissionError): custom_template = 'exceptions/permission_error.html' # Return a custom error message, or fall back to Django's default 500 error handling diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py index 4b04c03e1..3008fc39a 100644 --- a/netbox/utilities/models.py +++ b/netbox/utilities/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import models from extras.models import ObjectChange diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index 9ebbbab57..b49e38048 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf import settings from django.core.paginator import Paginator, Page @@ -9,7 +7,7 @@ class EnhancedPaginator(Paginator): def __init__(self, object_list, per_page, **kwargs): if not isinstance(per_page, int) or per_page < 1: per_page = getattr(settings, 'PAGINATE_COUNT', 50) - super(EnhancedPaginator, self).__init__(object_list, per_page, **kwargs) + super().__init__(object_list, per_page, **kwargs) def _get_page(self, *args, **kwargs): return EnhancedPage(*args, **kwargs) diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py index ac2c70624..d76bc339e 100644 --- a/netbox/utilities/sql.py +++ b/netbox/utilities/sql.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import connections, models from django.db.models.sql.compiler import SQLCompiler @@ -7,7 +5,7 @@ from django.db.models.sql.compiler import SQLCompiler class NullsFirstSQLCompiler(SQLCompiler): def get_order_by(self): - result = super(NullsFirstSQLCompiler, self).get_order_by() + result = super().get_order_by() if result: return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] return result @@ -30,5 +28,5 @@ class NullsFirstQuerySet(models.QuerySet): """ def __init__(self, model=None, query=None, using=None, hints=None): - super(NullsFirstQuerySet, self).__init__(model, query, using, hints) + super().__init__(model, query, using, hints) self.query = query or NullsFirstQuery(self.model) diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index e531b5e32..3564136ac 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django.utils.safestring import mark_safe @@ -9,7 +7,7 @@ class BaseTable(tables.Table): Default table for object lists """ def __init__(self, *args, **kwargs): - super(BaseTable, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Set default empty_text if none was provided if self.empty_text is None: @@ -28,7 +26,7 @@ class ToggleColumn(tables.CheckBoxColumn): def __init__(self, *args, **kwargs): default = kwargs.pop('default', '') visible = kwargs.pop('visible', False) - super(ToggleColumn, self).__init__(*args, default=default, visible=visible, **kwargs) + super().__init__(*args, default=default, visible=visible, **kwargs) @property def header(self): @@ -48,3 +46,13 @@ class BooleanColumn(tables.Column): else: rendered = '' return mark_safe(rendered) + + +class ColorColumn(tables.Column): + """ + Display a color (#RRGGBB). + """ + def render(self, value): + return mark_safe( + ' '.format(value) + ) diff --git a/netbox/utilities/templates/widgets/select_contenttype.html b/netbox/utilities/templates/widgets/select_contenttype.html new file mode 100644 index 000000000..04c42c371 --- /dev/null +++ b/netbox/utilities/templates/widgets/select_contenttype.html @@ -0,0 +1 @@ + diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index 3090f4538..b9a8bf6ec 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import template from extras.models import ExportTemplate diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 39959a668..e7bc40846 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import datetime import json @@ -7,6 +5,9 @@ from django import template from django.utils.safestring import mark_safe from markdown import markdown +from utilities.forms import unpack_grouped_choices + + register = template.Library() @@ -22,6 +23,17 @@ def oneline(value): return value.replace('\n', ' ') +@register.filter() +def placeholder(value): + """ + Render a muted placeholder if value equates to False. + """ + if value: + return value + placeholder = '' + return mark_safe(placeholder) + + @register.filter() def getlist(value, arg): """ @@ -96,6 +108,8 @@ def humanize_speed(speed): 100000 => "100 Mbps" 10000000 => "10 Gbps" """ + if not speed: + return '' if speed >= 1000000000 and speed % 1000000000 == 0: return '{} Tbps'.format(int(speed / 1000000000)) elif speed >= 1000000 and speed % 1000000 == 0: @@ -115,14 +129,16 @@ def example_choices(field, arg=3): """ examples = [] if hasattr(field, 'queryset'): - choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]] + choices = [ + (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1] + ] else: choices = field.choices - for id, label in choices: + for value, label in unpack_grouped_choices(choices): if len(examples) == arg: examples.append('etc.') break - if not id or not label: + if not value or not label: continue examples.append(label) return ', '.join(examples) or 'None' diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index dcc564dfa..86fa8c836 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth.models import User from rest_framework.test import APITestCase as _APITestCase diff --git a/netbox/utilities/tests/test_managers.py b/netbox/utilities/tests/test_managers.py index 0bafaefde..7ff23b69d 100644 --- a/netbox/utilities/tests/test_managers.py +++ b/netbox/utilities/tests/test_managers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.test import TestCase from dcim.models import Site diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 642242d30..1d1f12ddb 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,12 +1,11 @@ -from __future__ import unicode_literals - from collections import OrderedDict + import datetime import json -import six from django.core.serializers import serialize -from django.http import HttpResponse + +from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER def csv_format(data): @@ -26,7 +25,7 @@ def csv_format(data): value = value.isoformat() # Force conversion to string first so we can check for any commas - if not isinstance(value, six.string_types): + if not isinstance(value, str): value = '{}'.format(value) # Double-quote the value if it contains a comma @@ -38,32 +37,6 @@ def csv_format(data): return ','.join(csv) -def queryset_to_csv(queryset): - """ - Export a queryset of objects as CSV, using the model's to_csv() method. - """ - output = [] - - # Start with the column headers - headers = ','.join(queryset.model.csv_headers) - output.append(headers) - - # Iterate through the queryset - for obj in queryset: - data = csv_format(obj.to_csv()) - output.append(data) - - # Build the HTTP response - response = HttpResponse( - '\n'.join(output), - content_type='text/csv' - ) - filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - - return response - - def foreground_color(bg_color): """ Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format. @@ -123,3 +96,21 @@ def deepmerge(original, new): else: merged[key] = val return merged + + +def to_meters(length, unit): + """ + Convert the given length to meters. + """ + length = int(length) + if length < 0: + raise ValueError("Length must be a positive integer") + if unit == LENGTH_UNIT_METER: + return length + if unit == LENGTH_UNIT_CENTIMETER: + return length / 100 + if unit == LENGTH_UNIT_FOOT: + return length * 0.3048 + if unit == LENGTH_UNIT_INCH: + return length * 0.3048 * 12 + raise ValueError("Unknown unit {}. Must be 'm', 'cm', 'ft', or 'in'.".format(unit)) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 102e368a5..cfa733208 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import re from django.core.validators import _lazy_re_compile, URLValidator diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index ef042176e..ee13533bc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,8 +1,6 @@ -from __future__ import unicode_literals - +import sys from collections import OrderedDict from copy import deepcopy -import sys from django.conf import settings from django.contrib import messages @@ -11,7 +9,7 @@ from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import Count, ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.http import HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render from django.template import loader from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError @@ -25,9 +23,8 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate -from utilities.utils import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField -from .constants import M2M_FIELD_TYPES +from utilities.utils import csv_format from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator @@ -60,7 +57,7 @@ class GetReturnURLMixin(object): # First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe. query_param = request.GET.get('return_url') - if query_param and is_safe_url(url=query_param, host=request.get_host()): + if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()): return query_param # Next, check if the object being modified (if any) has an absolute URL. @@ -91,6 +88,23 @@ class ObjectListView(View): table = None template_name = None + def queryset_to_csv(self): + """ + Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method. + """ + csv_data = [] + + # Start with the column headers + headers = ','.join(self.queryset.model.csv_headers) + csv_data.append(headers) + + # Iterate through the queryset appending each object + for obj in self.queryset: + data = csv_format(obj.to_csv()) + csv_data.append(data) + + return csv_data + def get(self, request): model = self.queryset.model @@ -116,9 +130,17 @@ class ObjectListView(View): request, "There was an error rendering the selected export template ({}).".format(et.name) ) - # Fall back to built-in CSV export if no template was specified + + # Fall back to built-in CSV formatting if export requested but no template specified elif 'export' in request.GET and hasattr(model, 'to_csv'): - return queryset_to_csv(self.queryset) + data = self.queryset_to_csv() + response = HttpResponse( + '\n'.join(data), + content_type='text/csv' + ) + filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list self.queryset = self.alter_queryset(request) @@ -140,7 +162,7 @@ class ObjectListView(View): # Apply the request context paginate = { - 'klass': EnhancedPaginator, + 'paginator_class': EnhancedPaginator, 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) } RequestConfig(request, paginate).configure(table) @@ -228,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View): return redirect(request.get_full_path()) return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): return redirect(return_url) else: return redirect(self.get_return_url(request, obj)) @@ -286,7 +308,7 @@ class ObjectDeleteView(GetReturnURLMixin, View): messages.success(request, msg) return_url = form.cleaned_data.get('return_url') - if return_url is not None and is_safe_url(url=return_url, host=request.get_host()): + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): return redirect(return_url) else: return redirect(self.get_return_url(request, obj)) @@ -713,10 +735,11 @@ class ComponentCreateView(View): data = deepcopy(request.POST) data[self.parent_field] = parent.pk - for name in form.cleaned_data['name_pattern']: + for i, name in enumerate(form.cleaned_data['name_pattern']): # Initialize the individual component form data['name'] = name + data.update(form.get_iterative_data(i)) component_form = self.model_form(data) if component_form.is_valid(): diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py new file mode 100644 index 000000000..fb6e2b0be --- /dev/null +++ b/netbox/virtualization/api/nested_serializers.py @@ -0,0 +1,62 @@ +from rest_framework import serializers + +from dcim.models import Interface +from utilities.api import WritableNestedSerializer +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine + +__all__ = [ + 'NestedClusterGroupSerializer', + 'NestedClusterSerializer', + 'NestedClusterTypeSerializer', + 'NestedInterfaceSerializer', + 'NestedVirtualMachineSerializer', +] + +# +# Clusters +# + + +class NestedClusterTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') + + class Meta: + model = ClusterType + fields = ['id', 'url', 'name', 'slug'] + + +class NestedClusterGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') + + class Meta: + model = ClusterGroup + fields = ['id', 'url', 'name', 'slug'] + + +class NestedClusterSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + + class Meta: + model = Cluster + fields = ['id', 'url', 'name'] + + +# +# Virtual machines +# + +class NestedVirtualMachineSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') + + class Meta: + model = VirtualMachine + fields = ['id', 'url', 'name'] + + +class NestedInterfaceSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') + virtual_machine = NestedVirtualMachineSerializer(read_only=True) + + class Meta: + model = Interface + fields = ['id', 'url', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 80fa73002..1b06dab3b 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,21 +1,21 @@ -from __future__ import unicode_literals - from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField -from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer -from ipam.models import IPAddress, VLAN -from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.models import VLAN +from tenancy.api.nested_serializers import NestedTenantSerializer +from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer from virtualization.constants import VM_STATUS_CHOICES from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine +from .nested_serializers import * # -# Cluster types +# Clusters # class ClusterTypeSerializer(ValidatedModelSerializer): @@ -25,18 +25,6 @@ class ClusterTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterTypeSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') - - class Meta: - model = ClusterType - fields = ['id', 'url', 'name', 'slug'] - - -# -# Cluster groups -# - class ClusterGroupSerializer(ValidatedModelSerializer): class Meta: @@ -44,18 +32,6 @@ class ClusterGroupSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug'] -class NestedClusterGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') - - class Meta: - model = ClusterGroup - fields = ['id', 'url', 'name', 'slug'] - - -# -# Clusters -# - class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) @@ -69,27 +45,10 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): ] -class NestedClusterSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') - - class Meta: - model = Cluster - fields = ['id', 'url', 'name'] - - # # Virtual machines # -# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency -class VirtualMachineIPAddressSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - - class Meta: - model = IPAddress - fields = ['id', 'url', 'family', 'address'] - - class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) site = NestedSiteSerializer(read_only=True) @@ -97,17 +56,17 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) - primary_ip = VirtualMachineIPAddressSerializer(read_only=True) - primary_ip4 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) - primary_ip6 = VirtualMachineIPAddressSerializer(required=False, allow_null=True) + primary_ip = NestedIPAddressSerializer(read_only=True) + primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) + primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) class Meta: model = VirtualMachine fields = [ 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', - 'local_context_data', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', + 'created', 'last_updated', ] @@ -116,44 +75,27 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', - 'local_context_data', + 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', + 'config_context', 'created', 'last_updated', ] def get_config_context(self, obj): return obj.get_config_context() -class NestedVirtualMachineSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') - - class Meta: - model = VirtualMachine - fields = ['id', 'url', 'name'] - - # # VM interfaces # -# Cannot import ipam.api.serializers.NestedVLANSerializer due to circular dependency -class InterfaceVLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') - - class Meta: - model = VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] - - class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) - untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) + untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), - serializer=InterfaceVLANSerializer, + serializer=NestedVLANSerializer, required=False, many=True ) @@ -165,12 +107,3 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] - - -class NestedInterfaceSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') - virtual_machine = NestedVirtualMachineSerializer(read_only=True) - - class Meta: - model = Interface - fields = ['id', 'url', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 45db6aa6a..b27e5be3d 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from rest_framework import routers from . import views @@ -17,7 +15,7 @@ router = routers.DefaultRouter() router.APIRootView = VirtualizationRootView # Field choices -router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, base_name='field-choice') +router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice') # Clusters router.register(r'cluster-types', views.ClusterTypeViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index c3d644b8f..3b0c02b22 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from dcim.models import Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet @@ -25,19 +23,19 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): class ClusterTypeViewSet(ModelViewSet): queryset = ClusterType.objects.all() serializer_class = serializers.ClusterTypeSerializer - filter_class = filters.ClusterTypeFilter + filterset_class = filters.ClusterTypeFilter class ClusterGroupViewSet(ModelViewSet): queryset = ClusterGroup.objects.all() serializer_class = serializers.ClusterGroupSerializer - filter_class = filters.ClusterGroupFilter + filterset_class = filters.ClusterGroupFilter class ClusterViewSet(CustomFieldModelViewSet): queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags') serializer_class = serializers.ClusterSerializer - filter_class = filters.ClusterFilter + filterset_class = filters.ClusterFilter # @@ -48,7 +46,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): queryset = VirtualMachine.objects.select_related( 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6' ).prefetch_related('tags') - filter_class = filters.VirtualMachineFilter + filterset_class = filters.VirtualMachineFilter def get_serializer_class(self): """ @@ -69,7 +67,7 @@ class InterfaceViewSet(ModelViewSet): virtual_machine__isnull=False ).select_related('virtual_machine').prefetch_related('tags') serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter + filterset_class = filters.InterfaceFilter def get_serializer_class(self): request = self.get_serializer_context()['request'] diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 768508cfb..35d6e8266 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.apps import AppConfig diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py index 307921e0e..37e9efea2 100644 --- a/netbox/virtualization/constants.py +++ b/netbox/virtualization/constants.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED # VirtualMachine statuses (replicated from Device statuses) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 5f0f834cc..a103e9b29 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_filters from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q @@ -29,7 +27,10 @@ class ClusterGroupFilter(django_filters.FilterSet): class ClusterFilter(CustomFieldFilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -39,7 +40,7 @@ class ClusterFilter(CustomFieldFilterSet): label='Parent group (ID)', ) group = django_filters.ModelMultipleChoiceFilter( - name='group__slug', + field_name='group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', label='Parent group (slug)', @@ -49,7 +50,7 @@ class ClusterFilter(CustomFieldFilterSet): label='Cluster type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( - name='type__slug', + field_name='type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', label='Cluster type (slug)', @@ -59,7 +60,7 @@ class ClusterFilter(CustomFieldFilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -80,7 +81,10 @@ class ClusterFilter(CustomFieldFilterSet): class VirtualMachineFilter(CustomFieldFilterSet): - id__in = NumericInFilter(name='id', lookup_expr='in') + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) q = django_filters.CharFilter( method='search', label='Search', @@ -90,23 +94,23 @@ class VirtualMachineFilter(CustomFieldFilterSet): null_value=None ) cluster_group_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__group', + field_name='cluster__group', queryset=ClusterGroup.objects.all(), label='Cluster group (ID)', ) cluster_group = django_filters.ModelMultipleChoiceFilter( - name='cluster__group__slug', + field_name='cluster__group__slug', queryset=ClusterGroup.objects.all(), to_field_name='slug', label='Cluster group (slug)', ) cluster_type_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__type', + field_name='cluster__type', queryset=ClusterType.objects.all(), label='Cluster type (ID)', ) cluster_type = django_filters.ModelMultipleChoiceFilter( - name='cluster__type__slug', + field_name='cluster__type__slug', queryset=ClusterType.objects.all(), to_field_name='slug', label='Cluster type (slug)', @@ -117,21 +121,21 @@ class VirtualMachineFilter(CustomFieldFilterSet): ) region_id = django_filters.NumberFilter( method='filter_region', - name='pk', + field_name='pk', label='Region (ID)', ) region = django_filters.CharFilter( method='filter_region', - name='slug', + field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='cluster__site', + field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='cluster__site__slug', + field_name='cluster__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -141,7 +145,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role__slug', + field_name='role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -151,7 +155,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Tenant (ID)', ) tenant = django_filters.ModelMultipleChoiceFilter( - name='tenant__slug', + field_name='tenant__slug', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -161,7 +165,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): label='Platform (ID)', ) platform = django_filters.ModelMultipleChoiceFilter( - name='platform__slug', + field_name='platform__slug', queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', @@ -193,12 +197,12 @@ class VirtualMachineFilter(CustomFieldFilterSet): class InterfaceFilter(django_filters.FilterSet): virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine', + field_name='virtual_machine', queryset=VirtualMachine.objects.all(), label='Virtual machine (ID)', ) virtual_machine = django_filters.ModelMultipleChoiceFilter( - name='virtual_machine__name', + field_name='virtual_machine__name', queryset=VirtualMachine.objects.all(), to_field_name='name', label='Virtual machine', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 6d11ed78a..b1519f99b 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django import forms from django.core.exceptions import ValidationError from django.db.models import Count @@ -36,7 +34,9 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ClusterTypeCSVForm(forms.ModelForm): @@ -59,7 +59,9 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = [ + 'name', 'slug', + ] class ClusterGroupCSVForm(forms.ModelForm): @@ -78,12 +80,18 @@ class ClusterGroupCSVForm(forms.ModelForm): # class ClusterForm(BootstrapMixin, CustomFieldForm): - comments = CommentField(widget=SmallTextarea) - tags = TagField(required=False) + comments = CommentField( + widget=SmallTextarea() + ) + tags = TagField( + required=False + ) class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments', 'tags'] + fields = [ + 'name', 'type', 'group', 'site', 'comments', 'tags', + ] class ClusterCSVForm(forms.ModelForm): @@ -120,32 +128,54 @@ class ClusterCSVForm(forms.ModelForm): class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput) - type = forms.ModelChoiceField(queryset=ClusterType.objects.all(), required=False) - group = forms.ModelChoiceField(queryset=ClusterGroup.objects.all(), required=False) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=Cluster.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ModelChoiceField( + queryset=ClusterType.objects.all(), + required=False + ) + group = forms.ModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['group', 'site', 'comments'] + nullable_fields = [ + 'group', 'site', 'comments', + ] class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cluster q = forms.CharField(required=False, label='Search') type = FilterChoiceField( - queryset=ClusterType.objects.annotate(filter_count=Count('clusters')), + queryset=ClusterType.objects.annotate( + filter_count=Count('clusters') + ), to_field_name='slug', required=False, ) group = FilterChoiceField( - queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')), + queryset=ClusterGroup.objects.annotate( + filter_count=Count('clusters') + ), to_field_name='slug', null_label='-- None --', required=False, ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('clusters')), + queryset=Site.objects.annotate( + filter_count=Count('clusters') + ), to_field_name='slug', null_label='-- None --', required=False, @@ -157,7 +187,10 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): queryset=Region.objects.all(), required=False, widget=forms.Select( - attrs={'filter-for': 'site', 'nullable': 'true'} + attrs={ + 'filter-for': 'site', + 'nullable': 'true', + } ) ) site = ChainedModelChoiceField( @@ -168,7 +201,9 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): required=False, widget=APISelect( api_url='/api/dcim/sites/?region_id={{region}}', - attrs={'filter-for': 'rack'} + attrs={ + 'filter-for': 'rack', + } ) ) rack = ChainedModelChoiceField( @@ -179,7 +214,10 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): required=False, widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'devices', 'nullable': 'true'} + attrs={ + 'filter-for': 'devices', + 'nullable': 'true', + } ) ) devices = ChainedModelMultipleChoiceField( @@ -196,19 +234,20 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): ) class Meta: - fields = ['region', 'site', 'rack', 'devices'] + fields = [ + 'region', 'site', 'rack', 'devices', + ] def __init__(self, cluster, *args, **kwargs): self.cluster = cluster - super(ClusterAddDevicesForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.fields['devices'].choices = [] def clean(self): - - super(ClusterAddDevicesForm, self).clean() + super().clean() # If the Cluster is assigned to a Site, all Devices must be assigned to that Site. if self.cluster.site is not None: @@ -222,7 +261,10 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class ClusterRemoveDevicesForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + pk = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.MultipleHiddenInput() + ) # @@ -234,7 +276,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): queryset=ClusterGroup.objects.all(), required=False, widget=forms.Select( - attrs={'filter-for': 'cluster', 'nullable': 'true'} + attrs={ + 'filter-for': 'cluster', + 'nullable': 'true', + } ) ) cluster = ChainedModelChoiceField( @@ -246,8 +291,12 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): api_url='/api/virtualization/clusters/?group_id={{cluster_group}}' ) ) - tags = TagField(required=False) - local_context_data = JSONField(required=False) + tags = TagField( + required=False + ) + local_context_data = JSONField( + required=False + ) class Meta: model = VirtualMachine @@ -256,7 +305,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { - 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context", + 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " + "config context", } def __init__(self, *args, **kwargs): @@ -268,7 +318,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): initial['cluster_group'] = instance.cluster.group kwargs['initial'] = initial - super(VirtualMachineForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) if self.instance.pk: @@ -321,7 +371,9 @@ class VirtualMachineCSVForm(forms.ModelForm): } ) role = forms.ModelChoiceField( - queryset=DeviceRole.objects.filter(vm_role=True), + queryset=DeviceRole.objects.filter( + vm_role=True + ), required=False, to_field_name='name', help_text='Name of functional role', @@ -354,24 +406,61 @@ class VirtualMachineCSVForm(forms.ModelForm): class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) - status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='') - cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False) - role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False) - vcpus = forms.IntegerField(required=False, label='vCPUs') - memory = forms.IntegerField(required=False, label='Memory (MB)') - disk = forms.IntegerField(required=False, label='Disk (GB)') - comments = CommentField(widget=SmallTextarea) + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + status = forms.ChoiceField( + choices=add_blank_choice(VM_STATUS_CHOICES), + required=False, + initial='' + ) + cluster = forms.ModelChoiceField( + queryset=Cluster.objects.all(), + required=False + ) + role = forms.ModelChoiceField( + queryset=DeviceRole.objects.filter( + vm_role=True + ), + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False + ) + vcpus = forms.IntegerField( + required=False, + label='vCPUs' + ) + memory = forms.IntegerField( + required=False, + label='Memory (MB)' + ) + disk = forms.IntegerField( + required=False, + label='Disk (GB)' + ) + comments = CommentField( + widget=SmallTextarea() + ) class Meta: - nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + nullable_fields = [ + 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ] class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualMachine - q = forms.CharField(required=False, label='Search') + q = forms.CharField( + required=False, + label='Search' + ) cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', @@ -383,7 +472,9 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): null_label='-- None --' ) cluster_id = FilterChoiceField( - queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), + queryset=Cluster.objects.annotate( + filter_count=Count('virtual_machines') + ), label='Cluster' ) region = FilterTreeNodeMultipleChoiceField( @@ -392,12 +483,18 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, ) site = FilterChoiceField( - queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')), + queryset=Site.objects.annotate( + filter_count=Count('clusters__virtual_machines') + ), to_field_name='slug', null_label='-- None --' ) role = FilterChoiceField( - queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')), + queryset=DeviceRole.objects.filter( + vm_role=True + ).annotate( + filter_count=Count('virtual_machines') + ), to_field_name='slug', null_label='-- None --' ) @@ -408,12 +505,16 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), + queryset=Tenant.objects.annotate( + filter_count=Count('virtual_machines') + ), to_field_name='slug', null_label='-- None --' ) platform = FilterChoiceField( - queryset=Platform.objects.annotate(filter_count=Count('virtual_machines')), + queryset=Platform.objects.annotate( + filter_count=Count('virtual_machines') + ), to_field_name='slug', null_label='-- None --' ) @@ -424,7 +525,9 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): # class InterfaceForm(BootstrapMixin, forms.ModelForm): - tags = TagField(required=False) + tags = TagField( + required=False + ) class Meta: model = Interface @@ -444,8 +547,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): } def clean(self): - - super(InterfaceForm, self).clean() + super().clean() # Validate VLAN assignments tagged_vlans = self.cleaned_data['tagged_vlans'] @@ -462,13 +564,34 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(ComponentForm): - name_pattern = ExpandableNameField(label='Name') - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) - enabled = forms.BooleanField(required=False) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - mac_address = forms.CharField(required=False, label='MAC Address') - description = forms.CharField(max_length=100, required=False) - tags = TagField(required=False) + name_pattern = ExpandableNameField( + label='Name' + ) + form_factor = forms.ChoiceField( + choices=VIFACE_FF_CHOICES, + initial=IFACE_FF_VIRTUAL, + widget=forms.HiddenInput() + ) + enabled = forms.BooleanField( + required=False + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + mac_address = forms.CharField( + required=False, + label='MAC Address' + ) + description = forms.CharField( + max_length=100, + required=False + ) + tags = TagField( + required=False + ) def __init__(self, *args, **kwargs): @@ -476,17 +599,33 @@ class InterfaceCreateForm(ComponentForm): kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) - super(InterfaceCreateForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) - enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - description = forms.CharField(max_length=100, required=False) + pk = forms.ModelMultipleChoiceField( + queryset=Interface.objects.all(), + widget=forms.MultipleHiddenInput() + ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + description = forms.CharField( + max_length=100, + required=False + ) class Meta: - nullable_fields = ['mtu', 'description'] + nullable_fields = [ + 'mtu', 'description', + ] # @@ -494,12 +633,32 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): - pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput) - name_pattern = ExpandableNameField(label='Name') + pk = forms.ModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + widget=forms.MultipleHiddenInput() + ) + name_pattern = ExpandableNameField( + label='Name' + ) class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): - form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) - enabled = forms.BooleanField(required=False, initial=True) - mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') - description = forms.CharField(max_length=100, required=False) + form_factor = forms.ChoiceField( + choices=VIFACE_FF_CHOICES, + initial=IFACE_FF_VIRTUAL, + widget=forms.HiddenInput() + ) + enabled = forms.BooleanField( + required=False, + initial=True + ) + mtu = forms.IntegerField( + required=False, + min_value=1, + max_value=32767, + label='MTU' + ) + description = forms.CharField( + max_length=100, + required=False + ) diff --git a/netbox/virtualization/migrations/0001_virtualization.py b/netbox/virtualization/migrations/0001_virtualization.py index a5c7535cf..f34bee36c 100644 --- a/netbox/virtualization/migrations/0001_virtualization.py +++ b/netbox/virtualization/migrations/0001_virtualization.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-08-31 14:15 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py index 5b03b6e33..f9f5c72bd 100644 --- a/netbox/virtualization/migrations/0002_virtualmachine_add_status.py +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-14 17:49 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py index 295ec7d17..6ee06f912 100644 --- a/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py +++ b/netbox/virtualization/migrations/0002_virtualmachine_add_status_squashed_0004_virtualmachine_add_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.14 on 2018-07-31 02:23 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0003_cluster_add_site.py b/netbox/virtualization/migrations/0003_cluster_add_site.py index 5ac3c578b..bdcce88bc 100644 --- a/netbox/virtualization/migrations/0003_cluster_add_site.py +++ b/netbox/virtualization/migrations/0003_cluster_add_site.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-22 16:30 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py index 10dec60fa..db416fc5d 100644 --- a/netbox/virtualization/migrations/0004_virtualmachine_add_role.py +++ b/netbox/virtualization/migrations/0004_virtualmachine_add_role.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-29 14:32 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/netbox/virtualization/migrations/0006_tags.py b/netbox/virtualization/migrations/0006_tags.py index eed800852..5152086de 100644 --- a/netbox/virtualization/migrations/0006_tags.py +++ b/netbox/virtualization/migrations/0006_tags.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-22 19:04 -from __future__ import unicode_literals - from django.db import migrations import taggit.managers diff --git a/netbox/virtualization/migrations/0007_change_logging.py b/netbox/virtualization/migrations/0007_change_logging.py index 954f9f2a9..4c2d342e5 100644 --- a/netbox/virtualization/migrations/0007_change_logging.py +++ b/netbox/virtualization/migrations/0007_change_logging.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-06-13 17:14 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 119c9ee4f..ff9f39ee9 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,11 +1,8 @@ -from __future__ import unicode_literals - from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from taggit.managers import TaggableManager from dcim.models import Device @@ -18,7 +15,6 @@ from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSE # Cluster types # -@python_2_unicode_compatible class ClusterType(ChangeLoggedModel): """ A type of Cluster. @@ -53,7 +49,6 @@ class ClusterType(ChangeLoggedModel): # Cluster groups # -@python_2_unicode_compatible class ClusterGroup(ChangeLoggedModel): """ An organizational group of Clusters. @@ -88,7 +83,6 @@ class ClusterGroup(ChangeLoggedModel): # Clusters # -@python_2_unicode_compatible class Cluster(ChangeLoggedModel, CustomFieldModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. @@ -164,7 +158,6 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): # Virtual machines # -@python_2_unicode_compatible class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): """ A virtual machine which runs inside a Cluster. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 84579af49..b825ba59f 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import django_tables2 as tables from django_tables2.utils import Accessor diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 99e57b201..91792f8fb 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -15,7 +13,7 @@ class ClusterTypeTest(APITestCase): def setUp(self): - super(ClusterTypeTest, self).setUp() + super().setUp() self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2') @@ -116,7 +114,7 @@ class ClusterGroupTest(APITestCase): def setUp(self): - super(ClusterGroupTest, self).setUp() + super().setUp() self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2') @@ -217,7 +215,7 @@ class ClusterTest(APITestCase): def setUp(self): - super(ClusterTest, self).setUp() + super().setUp() cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') @@ -330,7 +328,7 @@ class VirtualMachineTest(APITestCase): def setUp(self): - super(VirtualMachineTest, self).setUp() + super().setUp() cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1') @@ -460,7 +458,7 @@ class InterfaceTest(APITestCase): def setUp(self): - super(InterfaceTest, self).setUp() + super().setUp() clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index b03b3bc0a..5fc5997a8 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.conf.urls import url from extras.views import ObjectChangeLogView diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d4728da45..b578cf455 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction diff --git a/requirements.txt b/requirements.txt index d3fc5a561..349a27717 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,18 @@ -Django>=1.11,<2.1 +Django==2.1.3 django-cors-headers==2.4.0 -django-debug-toolbar==1.9.1 -django-filter==1.1.0 +django-debug-toolbar==1.10.1 +django-filter==2.0.0 django-mptt==0.9.1 -django-tables2==1.21.2 -django-taggit==0.22.2 +django-tables2==2.0.3 +django-taggit==0.23.0 django-taggit-serializer==0.1.7 -django-timezone-field==2.1 -djangorestframework==3.8.1 -drf-yasg[validation]==1.9.2 -graphviz==0.8.4 +django-timezone-field==3.0 +djangorestframework==3.9.0 +drf-yasg[validation]==1.11.0 +graphviz==0.10.1 Markdown==2.6.11 -natsort==5.3.3 -ncclient==0.6.0 netaddr==0.7.19 -paramiko==2.4.2 -Pillow==5.2.0 -psycopg2-binary==2.7.5 -py-gfm==0.1.3 -pycryptodome==3.6.6 -xmltodict==0.11.0 - +Pillow==5.3.0 +psycopg2-binary==2.7.6.1 +py-gfm==0.1.4 +pycryptodome==3.7.1 diff --git a/upgrade.sh b/upgrade.sh index a1930eb3d..24e79f5bd 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -5,47 +5,22 @@ # Once the script completes, remember to restart the WSGI service (e.g. # gunicorn or uWSGI). -# Determine which version of Python/pip to use. Default to v3 (if available) -# but allow the user to force v2. PYTHON="python3" PIP="pip3" -type $PYTHON >/dev/null 2>&1 && type $PIP >/dev/null 2>&1 || PYTHON="python" PIP="pip" -while getopts ":2" opt; do - case $opt in - 2) - PYTHON="python" - PIP="pip" - echo "Forcing Python/pip v2" - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - exit - ;; - esac -done - -# Optionally use sudo if not already root, and always prompt for password -# before running the command -PREFIX="sudo -k " -if [ "$(whoami)" = "root" ]; then - # When running upgrade as root, ask user to confirm if they wish to - # continue - read -n1 -rsp $'Running NetBox upgrade as root, press any key to continue or ^C to cancel\n' - PREFIX="" -fi +# TODO: Remove this in v2.6 as it is no longer needed under Python 3 # Delete stale bytecode -COMMAND="${PREFIX}find . -name \"*.pyc\" -delete" +COMMAND="find . -name \"*.pyc\" -delete" echo "Cleaning up stale Python bytecode ($COMMAND)..." eval $COMMAND # Uninstall any Python packages which are no longer needed -COMMAND="${PREFIX}${PIP} uninstall -r old_requirements.txt -y" +COMMAND="${PIP} uninstall -r old_requirements.txt -y" echo "Removing old Python packages ($COMMAND)..." eval $COMMAND # Install any new Python packages -COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade" +COMMAND="${PIP} install -r requirements.txt --upgrade" echo "Updating required Python packages ($COMMAND)..." eval $COMMAND
Virtual CPUs - {% if virtualmachine.vcpus %} - {{ virtualmachine.vcpus }} - {% else %} - N/A - {% endif %} - {{ virtualmachine.vcpus|placeholder }}
Memory