diff --git a/.travis.yml b/.travis.yml index 33abc8425b0..13c6d406bb0 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 75dfe2dab2f..14670a1839d 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 5b090048d82..04e61029af8 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 6012ffa6c19..3d15784007e 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 5afd7876d60..2ebea5ce57f 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 b4de6fe7b1a..82412cdf799 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 e56c9d8c6cc..f41c94ec6c8 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 5ae599c73f4..e51bf541c29 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 bca60ca8911..6dc8a3c7a24 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 00000000000..211dc4007b8 --- /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 c19ab2fcefc..e94875c21df 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 3fb4eda0a77..b9d1b439b1b 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 eccc1edfc65..877d85f85ef 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 613c347f216..bc0b7d87de0 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 c13975b06dd..03a981ea19c 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 a159fad4288..0982624d5f6 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 aae8bb5f654..0c31e78d630 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 470fbee461c..dd4dc612b0c 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 1ae1c5d45a6..3fcec7933fa 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 32f31b37699..2d350b5f345 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 f1010064ef1..e1e9adab9ac 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 641b13afde8..de81f21eb9d 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 f309cb2d819..51b09ad4c8e 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 e5451498a7b..1a083c3dac4 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 023e5890a5c..238cb07dddd 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 14ee6686ded..b7ccafd263d 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 0f22a2268b4..0cc58fea956 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 3abe5d31988..675a0c1fba7 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 b3510f8f43d..11243622386 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 db505785860..c9a3ee41d96 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 00000000000..4e9125a9913 --- /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 00000000000..2b307042721 --- /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 6a2e55afca9..776b24156a8 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 40a1e1031c4..bdfe8c0b6a7 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 6bf3114d9c9..c6a215db883 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 bcaf2dee48e..0810f0ff93b 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 449da396467..be110630892 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 e116e455633..661f78e8e3b 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 8804da436b2..05ad86b5b48 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 00000000000..4d747859545 --- /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 94d2b07a8ee..765ed83dd08 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 145cb7f099c..006a61bad10 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 fd4d3709654..2c0032cb4fc 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 d61a46d9819..78a243f8493 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 d4182539076..47a202893d1 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 4f38ec24e45..8d4bfba3500 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 a8fb279543b..0d9deadc295 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 761f1ba69e8..215fbb70295 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 e765de2276b..83f79e3a3a1 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 4a75ac3868c..86da72a8813 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 00000000000..52df1afe836 --- /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 da18bdbfe17..db5f3faf2b8 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 e269d43f4b0..1e3aa4d2a6f 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 a641c3a2f2e..c3412cf10e9 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 deebc8518b2..312d0456c32 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 e051daded96..0806acb8262 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 83a5cf7cbff..a286d6ff35b 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 670a174f97d..6038cc02718 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 055eac7d07f..0d53337f7e7 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 91465e878ec..f43452de2ff 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 c93340ceacd..0a72a6cf4ee 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 bf2f31c575d..769a6f67874 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 62c97abc63d..eb77ea50046 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 8dcf8f81a5f..b01f507c301 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 310eb1eb687..478cb59ff8d 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 c14768c0f53..a3922c8cdbc 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 8e555204be5..167dd8f5424 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 6a2264a8392..7204e66260c 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 eb3560b37b8..48500f4b415 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 706b42ac4d1..84d1cef3586 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 b2358ba5e35..b2d8be53302 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 d5a74706d3a..7408c82ef14 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 9e85ac90933..bb4c4f4be22 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 97e5de9ca59..87fba47870c 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 677a8af9de8..5f70e80760b 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 a613552ad55..4d4cfb60392 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 34e17561f7b..218107ba2a8 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 d1263cb89ad..56db88f1cd8 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 b9d4f821421..ba66feea5d1 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 12d85f53e7c..bef85a82255 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 6e7c5211482..a67f34b3890 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 83906fc76f5..dd9f30bf2fb 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 6f5be67a4db..1ffd74f0452 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 d4fd4db5e54..73bb77b3f5d 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 e11e75bab3a..ff0cd137f80 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 b327bad1263..567de43454f 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 ff430c0676b..db2f0577a08 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 16ea807c933..a6f7aa5639b 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 ac0f89f41ef..ceed2263851 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 cba05beccdb..57ad7a744ef 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 61cdb3996cf..78ea103e5e4 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 4cc7e96161a..c5f8dc83d88 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 c7d49fe2ca9..aaca23ea826 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 73ca8f3ee7d..50c2fbd99cf 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 77bea6bc6f0..e667d9451f7 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 a52f5085923..9f0ba224321 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 b1e250bc2af..362979aefa7 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 42fc5f3177f..78b4e3a4144 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 775effaf268..306a5a80620 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 d040065242b..f6e93a43d5c 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 dafa81a5426..a76ef6c8d14 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 8e060c86503..3fb7c0d2e2c 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 ae9f95246ec..2d03db58781 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 1906b9179f5..8acaf4eec0c 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 90a551eb81a..ca0513ab070 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 334f60ca7d7..56777744ca3 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 62797716ef0..bb5f24c91c6 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 723f61fc80c..554bf554cd8 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 51cda0ff69a..ab23f403f7a 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 b0cccfdf32e..44ed0949769 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 e4974be2f27..9676e973d8d 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 15e666f3535..7c019ed5dd9 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 8a40f4e4efc..12a9f95ada5 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 00000000000..4926c4b322b --- /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 00000000000..a7fe9eab97e --- /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 00000000000..253167392a1 --- /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 00000000000..e78ccd8b6cd --- /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 00000000000..5ad4703e4ef --- /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 33885e203f8..5dcf8a49241 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 32275ce01b2..00000000000 --- 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 80e47391a05..2ac3bee0610 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 edd30d89fb6..b38a608277f 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 4c60e79d7bb..980c57e865b 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 c8d4387282f..2f333ea6915 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 5b2cdbd51cd..757af61f4bf 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 00000000000..d4dca43d719 --- /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 7345cdacd3a..dc1fbbf39c4 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 91b2a25a47a..632b58d233d 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 0549ce3172f..e747bf71ac7 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 0497138c4ba..7bf1c07447c 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 00000000000..11367aba94e --- /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 d0d2c67b089..7643562bb1d 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 cf61841ddfc..1bdcf181b34 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 0fefa7ae600..637ef235b43 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 4520b1923b0..2d4517c26af 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 9707d91211c..51fc398f767 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 3abd5b4cfa1..f3301a6cc17 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 6fc4b885943..c0d6732d13e 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 15b8acac5f5..c5a2fa1ecfa 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 c42bdf50aa4..00000000000 --- 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 96efc43a042..efc789021c8 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 7dfddbad6f8..16461c32a27 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 949b3a2d804..be9b952640c 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 1021c20c51b..c6167ff9f05 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 1d33ca28176..300ae758a8d 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 6355955b5f6..fc45f525521 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 ee838046d03..b35c641dad5 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 0f20e521492..58b66fe1ac1 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 c4c589a9ead..6842cced016 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 cda07583fde..fecb33b7b31 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 9c26f50ba35..e0c74753200 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 b062c58af71..bc9ec07d549 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 e35a2f835b9..dbff03e2de3 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 70c8e9c145e..8f7fcf36fb7 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 de4762a4622..01d73a84198 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 00000000000..eb750bc365c --- /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 00000000000..3b8852f44d6 --- /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 1605df6dfd5..d3b9f4eff07 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 bcc6f1e5487..439323c943a 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 52883063c7c..fc41b45f97b 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 552f592c7bd..00000000000 --- 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 cf2b6f88846..5fab8910f3d 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 3d0e5d1f702..cccb00a8a26 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 97eb69cd946..b02e787c11f 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 d4c0a79c67e..4f509a5e9ca 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 a97019a0424..12a2fbf6bb9 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 3e918649099..e7087e511bc 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 35ec56febce..12dc7558b6f 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 30f86f311ef..5a680f5d130 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 00000000000..2ffaa0ae21f --- /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 4ba62e8da0a..030266188b1 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 ca046cd93e3..9a2e1bc1f15 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 41cea7eaabb..e846f048902 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 c944d1b2c6d..fd4af74b07c 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 a675d3ca9be..eeb17eddd4b 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 8c7dbb6909d..1ddf545ea7d 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 700a25ae9e8..abef95c455e 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 c67c134141c..2909a54b175 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 8209b2ffa63..570716532af 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 9aca3c03b2d..e1de38a518a 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 f98d049522e..567f991eced 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 373e93d8032..993020a1275 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 c4271ea512f..61d38a69b97 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 2e7157fe124..c9092f0f2f2 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 fef5ec0b3d0..d8f628c57e8 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 17eee6e8c00..726b89259ba 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 8d519261def..9352e487290 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 eab3b9a472a..dfe8fbb521e 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 3ad3eb9e315..ea219da1920 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 ad876c3b6b7..b2859073048 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 a1e05171df9..2a7e0633544 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 ad773265328..d8b81d484ad 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 bb627440818..12b2cf67390 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 fd149361041..194bcb65130 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 adc8e606c7b..3f5f48437dd 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 18d82cbaf2d..5471e33e277 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 bb29542ad5c..6807bc55519 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 d91c3daa983..11bf372941c 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 77e083ef3de..3d318435400 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 955ff8b4ab0..f8ffbca11b4 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 c8292bbc07e..e271685a0b0 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 9d16be04985..e15c12a3269 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 878c02d8c3e..7f74115b630 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 14a508317ab..642bccc0577 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 d548fdf15ef..afb732d64fb 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 ef3bc6c3046..ca3f812a317 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 f606ab1b4c6..bfb2525f233 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 261c047df9c..284bcb4ae93 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 67b7e123ef0..d57cb728f49 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 d17e8f5ef09..f7f1705ff1a 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 700d78ae49c..c2f7badd3b7 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 2e3e0105c04..3a4c3617386 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 34faba23332..61796aabdae 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 a0a9e91465e..60c493be7fb 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 23d6ba22179..d7a9cf2edcf 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 434377024f5..d5ab0941093 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 be34f2be358..7b004ce8fb6 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 9354e24b9ab..45c99beb9c2 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 75d3dd182aa..263acb8eec6 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 ecfd81d9ad0..137f057c007 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 56104515c5c..155dd21b763 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 00000000000..2307cef8724 --- /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 6cb621071ed..89bc3aee147 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 2d5afe70085..92902acfd50 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 4eeac519ca6..94ede4545a8 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 00000000000..819546c63cb --- /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 ee7217b635c..1faf85dcf0f 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 2a24c445a99..def87b3a1ac 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 01567be8b59..0c164de07da 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 bc3714966b4..eec54bd7f46 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 0b9ebc16e40..e2f44ac90f0 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 f014d8a14d6..11433d41e1f 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 aa7e02e43d9..5880fb9f900 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 59e637a18c7..4e84ff78da6 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 49da1605dab..fc5066fc642 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 8dc0d54c6fd..1281a266a87 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 fb7d374319d..04db89e7cbe 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 4cd885cfbd2..03abfb70e5a 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 b8b7956d84f..48be221c5bc 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 ac952dc9206..bdba7980427 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 94708793455..d920e6fb2e3 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 6beb86c9e95..8190cd1dd94 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 c5595e1d3b3..c9732c5fe2d 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 4cfb1a6ea91..39d260a6de8 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 0e1ff554c60..142c0d2cba8 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 d8d156ef311..c260f1a482f 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 2fb7c3781a5..b3ba0cee10b 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 952725b5400..e1ce2b8f223 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 d15c9cbc25c..91d8caf0da9 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 1da608a4860..c09061c108b 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 2a9da7b15a9..f935f078ea3 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 5b15782c9e1..edbab3ed4b4 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 a2c7d966fc8..2bbc4695d0c 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 a1b236dcc86..a3cb09b25c5 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 157c6691835..46fd8afc74c 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 82cc86a7a14..e82d880adc8 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 00000000000..e1d3e4d8bd8 --- /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 00000000000..4b431ed1678 --- /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 00000000000..17403a07d41 --- /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 00000000000..07336e78cc8 --- /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 00000000000..fc645e6440c --- /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 89eb0822dca..cf47d426cde 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 679540960c8..00000000000 --- 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 dfd5cf2e7fb..00000000000 --- 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 98910432913..00000000000 --- 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 5c059446405..00000000000 --- 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 faa946f2eb6..860a3aa4431 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 4e351eb6a90..a41d571fb57 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 6c7cef4498f..00000000000 --- 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 81372033b57..00000000000 --- 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 1ffa6de2890..00000000000 --- 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 f98694d9fd8..00000000000 --- 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 ebe9a887077..1d92aac2240 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 d500a195424..cd1192c19cf 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 f592434c427..0407b67f627 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 097cb487f12..2665473fccc 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 c987daf3327..6c14b7b16c5 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 d6af5645849..76d90bad71d 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 b5b3ee2c1db..f6982bd6520 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 e469048af0a..52d9c2d6ee3 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 29eaf18bf5d..b70b9115fd5 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 940a871573a..6d3119d5b96 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 2d2fc464407..be196aa57f7 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 a460f80e813..169f16b118c 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 6068a710209..91d3ce98675 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 31bc73f3e7a..6f58bb45094 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 1a4b5c6c5b7..9f71b9633e7 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 92933d78bc6..00000000000 --- 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 c590f442341..40c3715b03b 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 0a792e0c4f7..cdb946c57bc 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 9f8ec8308cd..1556c5af089 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 00000000000..d26ac4675dc --- /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 592e35a6ebb..80f3b948d06 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 a36a1ec3d15..3da0e0f8217 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 48cd76163da..af3e318fc54 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 df2cd2fbb07..53cb9a056c9 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 4ff620d397b..5b3ec30d4c8 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 b909349231e..4c57453ca0c 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 ed2f800ef53..fcad19413db 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 95b1138ac51..3d91b76ecd9 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 d4258f4dcf1..77dc55975fe 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 ed547c51098..24cc7f969f1 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 5cb9398b5b3..dbea49cd0ff 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 7712e9d02e8..eb097936672 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 5a22143d325..045679b90ef 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 2e763591abd..91122df7a67 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 78b907d2044..69db73ac607 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 2da03b7f5e1..19522e6c757 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 fdb453665b8..97334c9f0cb 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 ba7a0f912a1..a0c368916be 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 00000000000..d1b64971360 --- /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 861bdade9a4..86d350e691b 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 d25e128e6e5..641a1f3e89b 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 d766b2ef00d..3e2ea274e21 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 54a6078a051..1c82a092d65 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 8a7f96bbd1a..d0cf75fd8a9 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 15f4f46f45e..2956a977887 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 aad89e10462..a45f859e71f 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 bc8263202c0..171d444b98d 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 9b9dabef5ec..c24fd1a169e 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 1cb3999ef05..64c2fab85f8 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 dab35e9820d..06c5c8784e6 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 3b7eb7a5b92..da851095025 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 34f59fe1601..104902b1fb0 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 a7f23d2f66b..4da3a9856a4 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 f54b418ca42..46dbcc78904 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 00000000000..e69de29bb2d diff --git a/netbox/utilities/management/commands/__init__.py b/netbox/utilities/management/commands/__init__.py new file mode 100644 index 00000000000..697a3ed9a7b --- /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 00000000000..fbcf82eafd4 --- /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 00000000000..2aa51b7136f --- /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 b112f4fae8c..724773c4628 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 dafafde244e..4e321ab1974 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 4b04c03e1f8..3008fc39ab4 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 9ebbbab5794..b49e3804863 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 ac2c7062462..d76bc339e26 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 e531b5e3238..3564136ac1a 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 00000000000..04c42c37149 --- /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 3090f45384d..b9a8bf6ec95 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 39959a6685e..e7bc408465c 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 dcc564dfa8e..86fa8c83603 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 0bafaefde86..7ff23b69d29 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 642242d30a0..1d1f12ddb3a 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 102e368a53c..cfa733208d3 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 ef042176e5b..ee13533bcf0 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 00000000000..fb6e2b0be67 --- /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 80fa73002a9..1b06dab3b4a 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 45db6aa6a96..b27e5be3de9 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 c3d644b8ff5..3b0c02b2233 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 768508cfb45..35d6e8266c0 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 307921e0ea6..37e9efea230 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 5f0f834ccac..a103e9b2958 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 6d11ed78a49..b1519f99b12 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 a5c7535cfd2..f34bee36cb2 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 5b03b6e33e7..f9f5c72bdac 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 295ec7d176a..6ee06f91248 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 5ac3c578bc1..bdcce88bc9d 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 10dec60fa21..db416fc5da5 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 eed800852e1..5152086de1a 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 954f9f2a902..4c2d342e577 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 119c9ee4ffa..ff9f39ee99d 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 84579af49f2..b825ba59f37 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 99e57b201a7..91792f8fb1f 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 b03b3bc0a40..5fc5997a853 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 d4728da4548..b578cf455af 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 d3fc5a561ea..349a2771771 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 a1930eb3d22..24e79f5bdb9 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