mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-31 17:47:45 -06:00
Compare commits
164 Commits
v2.5-beta2
...
v2.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf8710130 | ||
|
|
3705e37678 | ||
|
|
ebe5193348 | ||
|
|
a3097d254e | ||
|
|
38276d9539 | ||
|
|
91a2168952 | ||
|
|
4a10b4ece0 | ||
|
|
853b1fad15 | ||
|
|
7acbeb55bc | ||
|
|
8498e0088b | ||
|
|
aae10f7d71 | ||
|
|
6b19a2b101 | ||
|
|
b44a76e6bd | ||
|
|
7f71fc1d42 | ||
|
|
ba9fe408bc | ||
|
|
40cb576e11 | ||
|
|
2f1db2fdf3 | ||
|
|
f4a22e5af3 | ||
|
|
aca57ec281 | ||
|
|
68cb8b6895 | ||
|
|
82e8c0152e | ||
|
|
d4a9318826 | ||
|
|
27a893a9a1 | ||
|
|
9f1fcca5ea | ||
|
|
bb564363d5 | ||
|
|
dd2a6a41da | ||
|
|
a6c8c615eb | ||
|
|
0d3b1bfca4 | ||
|
|
edd763b1aa | ||
|
|
2418fed65b | ||
|
|
785cdcefd6 | ||
|
|
3480832bf5 | ||
|
|
ee038bd77b | ||
|
|
6460c95e00 | ||
|
|
b0a6781623 | ||
|
|
8364e56e86 | ||
|
|
b8a4316297 | ||
|
|
24d1707693 | ||
|
|
b4f79f1667 | ||
|
|
064dd9bef2 | ||
|
|
b697c30941 | ||
|
|
93c95fdfa8 | ||
|
|
8863a3126d | ||
|
|
acbe5f6418 | ||
|
|
4e6652d811 | ||
|
|
7d4fa69595 | ||
|
|
baeb7937fc | ||
|
|
2bd9f8a11f | ||
|
|
44a2919a29 | ||
|
|
77fbc42f75 | ||
|
|
65edffea63 | ||
|
|
bf0083552d | ||
|
|
869194354c | ||
|
|
aa8c836b94 | ||
|
|
9689ba2c4f | ||
|
|
703be259fd | ||
|
|
45a1dfbd8a | ||
|
|
360303f86c | ||
|
|
64d37cd450 | ||
|
|
71dee2758b | ||
|
|
870edbb44a | ||
|
|
2a07e8f3f0 | ||
|
|
686a65880e | ||
|
|
ab4cb46d94 | ||
|
|
d3d6c83fbb | ||
|
|
4e3567659a | ||
|
|
f0874f4be0 | ||
|
|
dffa2d3556 | ||
|
|
7bbf33ee39 | ||
|
|
90e7080b63 | ||
|
|
e6ee26cf0e | ||
|
|
0dcab07519 | ||
|
|
a3ade01224 | ||
|
|
232e6f5076 | ||
|
|
d1cd366dc9 | ||
|
|
a1a9396287 | ||
|
|
ca0248c3a2 | ||
|
|
a43fc0d3d3 | ||
|
|
08b4b24296 | ||
|
|
5acd429c55 | ||
|
|
6c2a9107dd | ||
|
|
879d879e56 | ||
|
|
c6d048ca51 | ||
|
|
112aaea51f | ||
|
|
c3cdf8e97e | ||
|
|
d2744700c6 | ||
|
|
5d07a5a670 | ||
|
|
4da755e75f | ||
|
|
bd7aee7c1f | ||
|
|
f3aef37163 | ||
|
|
7d262296e1 | ||
|
|
90a4b62976 | ||
|
|
7346083b26 | ||
|
|
f052bbc36e | ||
|
|
8d4329197a | ||
|
|
cb83eb204b | ||
|
|
74d525364a | ||
|
|
125975832b | ||
|
|
bcf22831e2 | ||
|
|
3b26ce6501 | ||
|
|
1b2d3bf08b | ||
|
|
492bc9f86e | ||
|
|
967feb6931 | ||
|
|
f224ad2959 | ||
|
|
242cb7c7cb | ||
|
|
ea7386b04b | ||
|
|
7a27dbb374 | ||
|
|
a85e6370a8 | ||
|
|
09a03565d7 | ||
|
|
6159994552 | ||
|
|
a1f624c1cc | ||
|
|
328958876a | ||
|
|
68f73c7f94 | ||
|
|
ec4d28ac6c | ||
|
|
957074a134 | ||
|
|
c4f7e8121a | ||
|
|
6436d703f5 | ||
|
|
ec0cb7a8bc | ||
|
|
e98f0c39d1 | ||
|
|
50a451eddc | ||
|
|
a5a7358d26 | ||
|
|
f9452163c5 | ||
|
|
3067c3f262 | ||
|
|
7a64404299 | ||
|
|
2bda399982 | ||
|
|
74731bc6ae | ||
|
|
7cb287d6c6 | ||
|
|
aa8f734bd1 | ||
|
|
f6d1163ddd | ||
|
|
5be30bd278 | ||
|
|
fa7b7288c9 | ||
|
|
9cc03aaa9a | ||
|
|
1bda56ea23 | ||
|
|
64a34ced72 | ||
|
|
e05d379101 | ||
|
|
a355783377 | ||
|
|
88239e0b0d | ||
|
|
5c63a499d5 | ||
|
|
50496b1a59 | ||
|
|
f7b0d22f86 | ||
|
|
ad95b86fdd | ||
|
|
43e1e0dbc8 | ||
|
|
f731900e2f | ||
|
|
b1bcaa33e7 | ||
|
|
17873706b7 | ||
|
|
e0ad2b4555 | ||
|
|
f89d91783b | ||
|
|
3ffe36e5ed | ||
|
|
be393a9d10 | ||
|
|
27eefd8705 | ||
|
|
097e0f38ff | ||
|
|
ce26b566a4 | ||
|
|
0e14bc1e02 | ||
|
|
ce6796ed9b | ||
|
|
c90cecc2fb | ||
|
|
b6bbcb0609 | ||
|
|
23f6832d9c | ||
|
|
88dace75a1 | ||
|
|
8eb140fd65 | ||
|
|
1f09f3d096 | ||
|
|
66be85a41f | ||
|
|
814c11167e | ||
|
|
57ddd5086f | ||
|
|
c171547037 |
106
CHANGELOG.md
106
CHANGELOG.md
@@ -1,8 +1,47 @@
|
||||
v2.5-beta2 (2018-11-26)
|
||||
v2.5.2 (2018-12-21)
|
||||
|
||||
## BETA RELEASE
|
||||
## Enhancements
|
||||
|
||||
**This is a beta release.** It is intended solely for gathering community and developer feedback in preparation for the v2.5 release. Do not run it in production, and do not give it write access to your production database. As the database schema is subject to change during the beta period, a migration path to the stable release likely will not be provided. Do not commit any data which you are not willing to lose.
|
||||
* [#2561](https://github.com/digitalocean/netbox/issues/2561) - Add 200G and 400G interface types
|
||||
* [#2701](https://github.com/digitalocean/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2673](https://github.com/digitalocean/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected
|
||||
* [#2691](https://github.com/digitalocean/netbox/issues/2691) - Cable trace should follow circuits
|
||||
* [#2698](https://github.com/digitalocean/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs
|
||||
* [#2704](https://github.com/digitalocean/netbox/issues/2704) - Fix form select widget population on parent with null value
|
||||
* [#2707](https://github.com/digitalocean/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling
|
||||
* [#2712](https://github.com/digitalocean/netbox/issues/2712) - Preserve list filtering after editing objects in bulk
|
||||
* [#2717](https://github.com/digitalocean/netbox/issues/2717) - Fix bulk deletion of tags
|
||||
* [#2721](https://github.com/digitalocean/netbox/issues/2721) - Detect loops when tracing front/rear ports
|
||||
* [#2723](https://github.com/digitalocean/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags
|
||||
* [#2724](https://github.com/digitalocean/netbox/issues/2724) - Limit rear port choices to current device when editing a front port
|
||||
|
||||
---
|
||||
|
||||
v2.5.1 (2018-12-13)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2655](https://github.com/digitalocean/netbox/issues/2655) - Add 128GFC Fibrechannel interface type
|
||||
* [#2674](https://github.com/digitalocean/netbox/issues/2674) - Enable filtering changelog by object type under web UI
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2662](https://github.com/digitalocean/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs
|
||||
* [#2663](https://github.com/digitalocean/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view
|
||||
* [#2666](https://github.com/digitalocean/netbox/issues/2666) - Correct display of length unit in cables list
|
||||
* [#2676](https://github.com/digitalocean/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField
|
||||
* [#2678](https://github.com/digitalocean/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission
|
||||
* [#2680](https://github.com/digitalocean/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint
|
||||
* [#2683](https://github.com/digitalocean/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
|
||||
* [#2684](https://github.com/digitalocean/netbox/issues/2684) - Fix custom field filtering
|
||||
* [#2687](https://github.com/digitalocean/netbox/issues/2687) - Correct naming of before/after filters for changelog entries
|
||||
|
||||
---
|
||||
|
||||
v2.5.0 (2018-12-10)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -18,6 +57,10 @@ The UserAction model, which was deprecated by the new change logging feature in
|
||||
|
||||
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))
|
||||
@@ -38,30 +81,19 @@ NetBox now supports modeling physical cables for console, power, and interface c
|
||||
* [#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-beta1
|
||||
## Changes From v2.5-beta2
|
||||
|
||||
* [#2554](https://github.com/digitalocean/netbox/issues/2554) - Fix cable trace display when following a rear port with no cable attached
|
||||
* [#2563](https://github.com/digitalocean/netbox/issues/2563) - Enable export templates for cables
|
||||
* [#2566](https://github.com/digitalocean/netbox/issues/2566) - Prevent both ends of a cable from connecting to the same termination point
|
||||
* [#2567](https://github.com/digitalocean/netbox/issues/2567) - Introduced proxy models to represent console/power/interface connections
|
||||
* [#2569](https://github.com/digitalocean/netbox/issues/2569) - Added LSH fiber type; removed SC duplex/simplex designations
|
||||
* [#2570](https://github.com/digitalocean/netbox/issues/2570) - Add bulk disconnect view for front/rear pass-through ports
|
||||
* [#2571](https://github.com/digitalocean/netbox/issues/2571) - Enforce deletion of attached cable when deleting a termination point
|
||||
* [#2572](https://github.com/digitalocean/netbox/issues/2572) - Add button to disconnect cable from circuit termination
|
||||
* [#2573](https://github.com/digitalocean/netbox/issues/2573) - Fix bulk console/power/interface disconnections
|
||||
* [#2574](https://github.com/digitalocean/netbox/issues/2574) - Remove duplicate interface links from topology maps
|
||||
* [#2578](https://github.com/digitalocean/netbox/issues/2578) - Reorganized nested serializers
|
||||
* [#2579](https://github.com/digitalocean/netbox/issues/2579) - Add missing cable disconnect buttons for front/rear ports
|
||||
* [#2583](https://github.com/digitalocean/netbox/issues/2583) - Cleaned up component filters for device and device type
|
||||
* [#2584](https://github.com/digitalocean/netbox/issues/2584) - Prevent a Front port from being connected to its corresponding rear port
|
||||
* [#2585](https://github.com/digitalocean/netbox/issues/2585) - Prevent cable connections that include a virtual interface
|
||||
* [#2586](https://github.com/digitalocean/netbox/issues/2586) - Added tests for the Cable model's clean() method
|
||||
* [#2593](https://github.com/digitalocean/netbox/issues/2593) - Fix toggling of connected cable's status
|
||||
* [#2601](https://github.com/digitalocean/netbox/issues/2601) - Added a `description` field to pass-through ports
|
||||
* [#2602](https://github.com/digitalocean/netbox/issues/2602) - Return HTTP 204 when no new IPs/prefixes are available for provisioning
|
||||
* [#2608](https://github.com/digitalocean/netbox/issues/2608) - Fixed null `outer_unit` error on rack import
|
||||
* [#2609](https://github.com/digitalocean/netbox/issues/2609) - Fixed exception when ChoiceField integer value is passed as a string
|
||||
* [#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
|
||||
|
||||
@@ -69,6 +101,8 @@ NetBox now supports modeling physical cables for console, power, and interface c
|
||||
* 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`
|
||||
@@ -82,6 +116,28 @@ NetBox now supports modeling physical cables for console, power, and interface c
|
||||
* 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
|
||||
|
||||
* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors
|
||||
* [#2495](https://github.com/digitalocean/netbox/issues/2495) - Enable deep-merging of config context data
|
||||
* [#2597](https://github.com/digitalocean/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2400](https://github.com/digitalocean/netbox/issues/2400) - Correct representation of nested object assignment in API docs
|
||||
* [#2576](https://github.com/digitalocean/netbox/issues/2576) - Correct type for count_* fields in site API representation
|
||||
* [#2606](https://github.com/digitalocean/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor
|
||||
* [#2611](https://github.com/digitalocean/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site
|
||||
* [#2613](https://github.com/digitalocean/netbox/issues/2613) - Decrease live search minimum characters to three
|
||||
* [#2615](https://github.com/digitalocean/netbox/issues/2615) - Tweak live search widget to use brief format for API requests
|
||||
* [#2623](https://github.com/digitalocean/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks
|
||||
* [#2634](https://github.com/digitalocean/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,19 +1,72 @@
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -44,7 +44,7 @@ class DeviceConnectionsReport(Report):
|
||||
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
|
||||
if console_port.cs_port is None:
|
||||
if console_port.connected_endpoint is None:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
@@ -63,7 +63,7 @@ class DeviceConnectionsReport(Report):
|
||||
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.power_outlet is not None:
|
||||
if power_port.connected_endpoint is not None:
|
||||
connected_ports += 1
|
||||
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
|
||||
self.log_warning(
|
||||
|
||||
@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
|
||||
}
|
||||
```
|
||||
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
|
||||
|
||||
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
||||
|
||||
@@ -122,6 +122,52 @@ When a base serializer includes one or more nested serializers, the hierarchical
|
||||
}
|
||||
```
|
||||
|
||||
## Brief Format
|
||||
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
|
||||
|
||||
For example, the default (complete) format of an IP address looks like this:
|
||||
|
||||
```
|
||||
GET /api/ipam/prefixes/13980/
|
||||
|
||||
{
|
||||
"id": 13980,
|
||||
"family": 4,
|
||||
"prefix": "192.0.2.0/24",
|
||||
"site": null,
|
||||
"vrf": null,
|
||||
"tenant": null,
|
||||
"vlan": null,
|
||||
"status": {
|
||||
"value": 1,
|
||||
"label": "Active"
|
||||
},
|
||||
"role": null,
|
||||
"is_pool": false,
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"custom_fields": {},
|
||||
"created": "2018-12-11",
|
||||
"last_updated": "2018-12-11T16:27:55.073174-05:00"
|
||||
}
|
||||
```
|
||||
|
||||
The brief format is much more terse, but includes a link to the object's full representation:
|
||||
|
||||
```
|
||||
GET /api/ipam/prefixes/13980/?brief=1
|
||||
|
||||
{
|
||||
"id": 13980,
|
||||
"url": "https://netbox/api/ipam/prefixes/13980/",
|
||||
"family": 4,
|
||||
"prefix": "192.0.2.0/24"
|
||||
}
|
||||
```
|
||||
|
||||
The brief format is supported for both lists and individual objects.
|
||||
|
||||
## Static Choice Fields
|
||||
|
||||
Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.
|
||||
|
||||
@@ -44,7 +44,11 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
|
||||
|
||||
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
|
||||
|
||||
### 6. Add field to forms
|
||||
### 6. Add choices to API view
|
||||
|
||||
If the new field has static choices, add it to the `FieldChoicesViewSet` for the app.
|
||||
|
||||
### 7. Add field to forms
|
||||
|
||||
Extend any forms to include the new field as appropriate. Common forms include:
|
||||
|
||||
@@ -53,18 +57,18 @@ Extend any forms to include the new field as appropriate. Common forms include:
|
||||
* **CSV import** - The form used when bulk importing objects in CSV format
|
||||
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
|
||||
|
||||
### 7. Extend object filter set
|
||||
### 8. Extend object filter set
|
||||
|
||||
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
|
||||
|
||||
### 8. Add column to object table
|
||||
### 9. Add column to object table
|
||||
|
||||
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
|
||||
|
||||
### 9. Update the UI templates
|
||||
### 10. Update the UI templates
|
||||
|
||||
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
|
||||
|
||||
### 10. Adjust API and model tests
|
||||
### 11. Adjust API and model tests
|
||||
|
||||
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.
|
||||
|
||||
@@ -28,6 +28,19 @@ To invoke `pycodestyle` manually, run:
|
||||
pycodestyle --ignore=W504,E501 netbox/
|
||||
```
|
||||
|
||||
## Introducing New Dependencies
|
||||
|
||||
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
|
||||
|
||||
If there's a strong case for introducing a new depdency, it must meet the following criteria:
|
||||
|
||||
* Its complete source code must be published and freely accessible without registration.
|
||||
* Its license must be conducive to inclusion in an open source project.
|
||||
* It must be actively maintained, with no longer than one year between releases.
|
||||
* It must be available via the [Python Package Index](https://pypi.org/) (PyPI).
|
||||
|
||||
When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts.
|
||||
|
||||
## General Guidance
|
||||
|
||||
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).)
|
||||
|
||||
!!! note
|
||||
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 7.4. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
!!! warning
|
||||
NetBox v2.2 and later requires PostgreSQL 9.4 or higher.
|
||||
@@ -19,7 +19,7 @@ If a recent enough version of PostgreSQL is not available through your distribut
|
||||
|
||||
**CentOS**
|
||||
|
||||
CentOS 7.4 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
|
||||
CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
|
||||
|
||||
```no-highlight
|
||||
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
|
||||
|
||||
@@ -5,16 +5,16 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
**Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python3 python3-dev python3-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||
# easy_install3 pip
|
||||
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||
```
|
||||
|
||||
**CentOS**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
|
||||
# easy_install-3.4 pip
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
|
||||
# easy_install-3.6 pip
|
||||
# ln -s /usr/bin/python36 /usr/bin/python3
|
||||
```
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
@@ -246,13 +246,13 @@ At this point, NetBox should be able to run. We can verify this by starting a de
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
June 17, 2016 - 16:17:36
|
||||
Django version 1.9.7, using settings 'netbox.settings'
|
||||
November 28, 2018 - 09:33:45
|
||||
Django version 2.0.9, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
|
||||
|
||||
!!! warning
|
||||
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
|
||||
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
# Web Server Installation
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ sudo yum install -y openldap-devel
|
||||
## Install django-auth-ldap
|
||||
|
||||
```no-highlight
|
||||
sudo pip install django-auth-ldap
|
||||
pip3 install django-auth-ldap
|
||||
```
|
||||
|
||||
# Configuration
|
||||
@@ -95,6 +95,9 @@ AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Mirror LDAP group assignments.
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
@@ -113,3 +116,21 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
|
||||
* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.
|
||||
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
|
||||
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
|
||||
|
||||
# Troubleshooting LDAP
|
||||
|
||||
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.
|
||||
|
||||
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
|
||||
|
||||
```python
|
||||
import logging, logging.handlers
|
||||
logfile = "/opt/netbox/logs/django-ldap-debug.log"
|
||||
my_logger = logging.getLogger('django_auth_ldap')
|
||||
my_logger.setLevel(logging.DEBUG)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
logfile, maxBytes=1024 * 500, backupCount=5)
|
||||
my_logger.addHandler(handler)
|
||||
```
|
||||
|
||||
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.
|
||||
|
||||
@@ -11,4 +11,4 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
|
||||
|
||||
NetBox v2.5 and later requires Python 3. Please see the instruction for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2.
|
||||
NetBox v2.5 and later requires Python 3.5 or higher. Please see the instructions for [migrating to Python 3](migrating-to-python3.md) if you are still using Python 2.
|
||||
|
||||
@@ -36,3 +36,9 @@ If using LDAP authentication, install the `django-auth-ldap` package:
|
||||
```no-highlight
|
||||
# pip3 install django-auth-ldap
|
||||
```
|
||||
|
||||
If using Webhooks, install the `django-rq` package:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install django-rq
|
||||
```
|
||||
|
||||
@@ -38,6 +38,7 @@ pages:
|
||||
- Change Logging: 'additional-features/change-logging.md'
|
||||
- Administration:
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
- API:
|
||||
- Overview: 'api/overview.md'
|
||||
- Authentication: 'api/authentication.md'
|
||||
|
||||
@@ -60,5 +60,5 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
]
|
||||
|
||||
@@ -21,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",
|
||||
@@ -54,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'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -82,7 +124,9 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['name', 'slug']
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
]
|
||||
|
||||
|
||||
class CircuitTypeCSVForm(forms.ModelForm):
|
||||
@@ -102,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
|
||||
@@ -157,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(
|
||||
@@ -188,15 +267,23 @@ 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)'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -176,7 +176,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
unique_together = ['provider', 'cid']
|
||||
|
||||
def __str__(self):
|
||||
return '{} {}'.format(self.provider, self.cid)
|
||||
return self.cid
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuit', args=[self.pk])
|
||||
|
||||
@@ -13,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')
|
||||
@@ -135,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')
|
||||
@@ -210,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')
|
||||
@@ -326,7 +326,7 @@ 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')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 WritableNestedSerializer
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCableSerializer',
|
||||
@@ -149,46 +150,51 @@ class NestedDeviceSerializer(WritableNestedSerializer):
|
||||
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']
|
||||
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']
|
||||
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']
|
||||
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']
|
||||
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']
|
||||
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
|
||||
|
||||
|
||||
class NestedRearPortSerializer(WritableNestedSerializer):
|
||||
|
||||
@@ -23,9 +23,18 @@ 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.
|
||||
@@ -58,6 +67,11 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneField(required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
count_prefixes = serializers.IntegerField(read_only=True)
|
||||
count_vlans = serializers.IntegerField(read_only=True)
|
||||
count_racks = serializers.IntegerField(read_only=True)
|
||||
count_devices = serializers.IntegerField(read_only=True)
|
||||
count_circuits = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
@@ -121,7 +135,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
validator(data)
|
||||
|
||||
# Enforce model validation
|
||||
super(RackSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -294,7 +308,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
validator(data)
|
||||
|
||||
# Enforce model validation
|
||||
super(DeviceSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -331,7 +345,10 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
@@ -341,7 +358,10 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
@@ -351,7 +371,10 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
@@ -361,7 +384,10 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'device', 'name', 'connected_endpoint', 'connection_status', 'cable', 'tags']
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
@@ -383,8 +409,8 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
|
||||
'count_ipaddresses',
|
||||
'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()
|
||||
@@ -405,7 +431,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
"be global.".format(vlan)
|
||||
})
|
||||
|
||||
return super(InterfaceSerializer, self).validate(data)
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(ValidatedModelSerializer):
|
||||
|
||||
@@ -35,12 +35,13 @@ from .exceptions import MissingFilterException
|
||||
|
||||
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(Cable, ['length_unit']),
|
||||
(Device, ['face', 'status']),
|
||||
(ConsolePort, ['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']),
|
||||
)
|
||||
|
||||
@@ -59,7 +60,7 @@ class CableTraceMixin(object):
|
||||
# Initialize the path array
|
||||
path = []
|
||||
|
||||
for near_end, cable, far_end in obj.trace():
|
||||
for near_end, cable, far_end in obj.trace(follow_circuits=True):
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
@@ -483,7 +484,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
filterset_class = filters.PowerConnectionFilter
|
||||
|
||||
|
||||
class InterfaceConnectionViewSet(ModelViewSet):
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.select_related(
|
||||
'device', '_connected_interface', '_connected_circuittermination'
|
||||
).filter(
|
||||
|
||||
@@ -82,18 +82,31 @@ IFACE_FF_100GE_CFP2 = 1510
|
||||
IFACE_FF_100GE_CFP4 = 1520
|
||||
IFACE_FF_100GE_CPAK = 1550
|
||||
IFACE_FF_100GE_QSFP28 = 1600
|
||||
IFACE_FF_200GE_CFP2 = 1650
|
||||
IFACE_FF_200GE_QSFP56 = 1700
|
||||
IFACE_FF_400GE_QSFP_DD = 1750
|
||||
# Wireless
|
||||
IFACE_FF_80211A = 2600
|
||||
IFACE_FF_80211G = 2610
|
||||
IFACE_FF_80211N = 2620
|
||||
IFACE_FF_80211AC = 2630
|
||||
IFACE_FF_80211AD = 2640
|
||||
# SONET
|
||||
IFACE_FF_SONET_OC3 = 6100
|
||||
IFACE_FF_SONET_OC12 = 6200
|
||||
IFACE_FF_SONET_OC48 = 6300
|
||||
IFACE_FF_SONET_OC192 = 6400
|
||||
IFACE_FF_SONET_OC768 = 6500
|
||||
IFACE_FF_SONET_OC1920 = 6600
|
||||
IFACE_FF_SONET_OC3840 = 6700
|
||||
# Fibrechannel
|
||||
IFACE_FF_1GFC_SFP = 3010
|
||||
IFACE_FF_2GFC_SFP = 3020
|
||||
IFACE_FF_4GFC_SFP = 3040
|
||||
IFACE_FF_8GFC_SFP_PLUS = 3080
|
||||
IFACE_FF_16GFC_SFP_PLUS = 3160
|
||||
IFACE_FF_32GFC_SFP28 = 3320
|
||||
IFACE_FF_128GFC_QSFP28 = 3400
|
||||
# Serial
|
||||
IFACE_FF_T1 = 4000
|
||||
IFACE_FF_E1 = 4010
|
||||
@@ -143,9 +156,12 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
||||
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
|
||||
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
|
||||
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
|
||||
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
|
||||
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
|
||||
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||
[IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
|
||||
[IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -158,6 +174,18 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_80211AD, 'IEEE 802.11ad'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'SONET',
|
||||
[
|
||||
[IFACE_FF_SONET_OC3, 'OC-3/STM-1'],
|
||||
[IFACE_FF_SONET_OC12, 'OC-12/STM-4'],
|
||||
[IFACE_FF_SONET_OC48, 'OC-48/STM-16'],
|
||||
[IFACE_FF_SONET_OC192, 'OC-192/STM-64'],
|
||||
[IFACE_FF_SONET_OC768, 'OC-768/STM-256'],
|
||||
[IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'],
|
||||
[IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'FibreChannel',
|
||||
[
|
||||
@@ -166,6 +194,8 @@ IFACE_FF_CHOICES = [
|
||||
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
|
||||
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
|
||||
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
|
||||
[IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
|
||||
[IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -360,11 +390,11 @@ COMPATIBLE_TERMINATION_TYPES = {
|
||||
'circuittermination': ['interface', 'frontport', 'rearport'],
|
||||
}
|
||||
|
||||
LENGTH_UNIT_METER = 'm'
|
||||
LENGTH_UNIT_CENTIMETER = 'cm'
|
||||
LENGTH_UNIT_MILLIMETER = 'mm'
|
||||
LENGTH_UNIT_FOOT = 'ft'
|
||||
LENGTH_UNIT_INCH = 'in'
|
||||
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'),
|
||||
|
||||
5
netbox/dcim/exceptions.py
Normal file
5
netbox/dcim/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class LoopDetected(Exception):
|
||||
"""
|
||||
A loop has been detected while tracing a cable path.
|
||||
"""
|
||||
pass
|
||||
@@ -7,6 +7,7 @@ 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 *
|
||||
@@ -698,37 +699,56 @@ 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',
|
||||
@@ -740,6 +760,11 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
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',
|
||||
@@ -762,15 +787,19 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='filter_vlan',
|
||||
label='Assigned VID'
|
||||
)
|
||||
form_factor = django_filters.MultipleChoiceFilter(
|
||||
choices=IFACE_FF_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['name', 'form_factor', '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.get(**{name: value})
|
||||
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||
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()
|
||||
@@ -814,6 +843,11 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class FrontPortFilter(DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
@@ -821,6 +855,11 @@ class FrontPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
|
||||
class RearPortFilter(DeviceComponentFilterSet):
|
||||
cabled = django_filters.BooleanFilter(
|
||||
field_name='cable',
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
@@ -929,6 +968,12 @@ class CableFilter(django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CABLE_TYPE_CHOICES
|
||||
)
|
||||
color = django_filters.MultipleChoiceFilter(
|
||||
choices=COLOR_CHOICES
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ class DeviceComponentManager(Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
queryset = super(DeviceComponentManager, self).get_queryset()
|
||||
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'))"
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='form_factor',
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -46,6 +46,9 @@ def console_connections_to_cables(apps, schema_editor):
|
||||
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):
|
||||
"""
|
||||
@@ -87,6 +90,9 @@ def power_connections_to_cables(apps, schema_editor):
|
||||
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):
|
||||
"""
|
||||
@@ -131,6 +137,15 @@ def interface_connections_to_cables(apps, schema_editor):
|
||||
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
|
||||
|
||||
@@ -157,7 +172,7 @@ class Migration(migrations.Migration):
|
||||
('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.CharField(blank=True, max_length=2)),
|
||||
('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')),
|
||||
@@ -291,7 +306,8 @@ class Migration(migrations.Migration):
|
||||
migrations.RunPython(power_connections_to_cables),
|
||||
migrations.RunPython(interface_connections_to_cables),
|
||||
|
||||
# Delete the InterfaceConnection model
|
||||
# Delete the InterfaceConnection model and its ContentType
|
||||
migrations.RunPython(delete_interfaceconnection_content_type),
|
||||
migrations.RemoveField(
|
||||
model_name='interfaceconnection',
|
||||
name='interface_a',
|
||||
@@ -303,36 +319,4 @@ class Migration(migrations.Migration):
|
||||
migrations.DeleteModel(
|
||||
name='InterfaceConnection',
|
||||
),
|
||||
|
||||
# Proxy models
|
||||
migrations.CreateModel(
|
||||
name='ConsoleConnection',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
},
|
||||
bases=('dcim.consoleport',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InterfaceConnection',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
},
|
||||
bases=('dcim.interface',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PowerConnection',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
},
|
||||
bases=('dcim.powerport',),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -28,7 +28,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='outer_unit',
|
||||
field=models.CharField(blank=True, max_length=2),
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
|
||||
@@ -21,6 +21,7 @@ from utilities.managers import NaturalOrderingManager
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
from .constants import *
|
||||
from .exceptions import LoopDetected
|
||||
from .fields import ASNField, MACAddressField
|
||||
from .managers import DeviceComponentManager, InterfaceManager
|
||||
|
||||
@@ -88,7 +89,7 @@ class CableTermination(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def trace(self, position=1, follow_circuits=False):
|
||||
def trace(self, position=1, follow_circuits=False, cable_history=None):
|
||||
"""
|
||||
Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
|
||||
[
|
||||
@@ -110,11 +111,14 @@ class CableTermination(models.Model):
|
||||
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
|
||||
try:
|
||||
peer_port = FrontPort.objects.get(
|
||||
rear_port=termination,
|
||||
rear_port_position=position,
|
||||
)
|
||||
return peer_port, 1
|
||||
except ObjectDoesNotExist:
|
||||
return None, None
|
||||
|
||||
# Follow a circuit to its other termination
|
||||
elif isinstance(termination, CircuitTermination) and follow_circuits:
|
||||
@@ -130,6 +134,13 @@ class CableTermination(models.Model):
|
||||
if not self.cable:
|
||||
return [(self, None, None)]
|
||||
|
||||
# Record cable history to detect loops
|
||||
if cable_history is None:
|
||||
cable_history = []
|
||||
elif self.cable in cable_history:
|
||||
raise LoopDetected()
|
||||
cable_history.append(self.cable)
|
||||
|
||||
far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
|
||||
path = [(self, self.cable, far_end)]
|
||||
|
||||
@@ -137,7 +148,11 @@ class CableTermination(models.Model):
|
||||
if peer_port is None:
|
||||
return path
|
||||
|
||||
next_segment = peer_port.trace(position)
|
||||
try:
|
||||
next_segment = peer_port.trace(position, follow_circuits, cable_history)
|
||||
except LoopDetected:
|
||||
return path
|
||||
|
||||
if next_segment is None:
|
||||
return path + [(peer_port, None, None)]
|
||||
|
||||
@@ -511,10 +526,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
outer_unit = models.CharField(
|
||||
outer_unit = models.PositiveSmallIntegerField(
|
||||
choices=RACK_DIMENSION_UNIT_CHOICES,
|
||||
max_length=2,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
@@ -544,7 +559,7 @@ 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])
|
||||
@@ -552,10 +567,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
def clean(self):
|
||||
|
||||
# Validate outer dimensions and unit
|
||||
if (self.outer_width or self.outer_depth) and not self.outer_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")
|
||||
else:
|
||||
self.outer_unit = ''
|
||||
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
|
||||
@@ -582,7 +597,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:
|
||||
@@ -894,7 +909,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
|
||||
@@ -1437,7 +1452,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])
|
||||
@@ -1552,7 +1567,7 @@ 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:
|
||||
@@ -2055,7 +2070,7 @@ class Interface(CableTermination, 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):
|
||||
"""
|
||||
@@ -2495,10 +2510,10 @@ class Cable(ChangeLoggedModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
length_unit = models.CharField(
|
||||
length_unit = models.PositiveSmallIntegerField(
|
||||
choices=CABLE_LENGTH_UNIT_CHOICES,
|
||||
max_length=2,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
# Stores the normalized length (in meters) for database ordering
|
||||
_abs_length = models.DecimalField(
|
||||
@@ -2522,7 +2537,7 @@ class Cable(ChangeLoggedModel):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(Cable, self).__init__(*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.
|
||||
@@ -2584,10 +2599,10 @@ class Cable(ChangeLoggedModel):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length and not self.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")
|
||||
if self.length_unit and self.length is None:
|
||||
self.length_unit = ''
|
||||
elif self.length is None:
|
||||
self.length_unit = None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -2595,7 +2610,7 @@ class Cable(ChangeLoggedModel):
|
||||
if self.length and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
|
||||
super(Cable, self).save(*args, **kwargs)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
@@ -2629,66 +2644,7 @@ class Cable(ChangeLoggedModel):
|
||||
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
|
||||
a_endpoint = a_path[-1][2]
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
||||
|
||||
#
|
||||
# Connection proxy models
|
||||
#
|
||||
|
||||
class ConsoleConnection(ConsolePort):
|
||||
|
||||
csv_headers = [
|
||||
'console_server', 'port', 'device', 'console_port', 'connection_status',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
|
||||
self.connected_endpoint.name if self.connected_endpoint else None,
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.get_connection_status_display(),
|
||||
)
|
||||
|
||||
|
||||
class PowerConnection(PowerPort):
|
||||
|
||||
csv_headers = [
|
||||
'pdu', 'outlet', 'device', 'power_port', 'connection_status',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
|
||||
self.connected_endpoint.name if self.connected_endpoint else None,
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.get_connection_status_display(),
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnection(Interface):
|
||||
|
||||
csv_headers = [
|
||||
'device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.connected_endpoint.device.identifier if self.connected_endpoint else None,
|
||||
self.connected_endpoint.name if self.connected_endpoint else None,
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.get_connection_status_display(),
|
||||
)
|
||||
return a_endpoint, b_endpoint, path_status
|
||||
|
||||
@@ -62,7 +62,7 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
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:
|
||||
if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
|
||||
endpoint_a.connected_endpoint = None
|
||||
endpoint_a.connection_status = None
|
||||
endpoint_a.save()
|
||||
|
||||
@@ -4,11 +4,10 @@ from django_tables2.utils import Accessor
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
|
||||
from .models import (
|
||||
Cable, ConsoleConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceConnection,
|
||||
InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerConnection, PowerOutlet, PowerOutletTemplate,
|
||||
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, 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 = """
|
||||
@@ -30,7 +29,8 @@ SITE_REGION_LINK = """
|
||||
"""
|
||||
|
||||
COLOR_LABEL = """
|
||||
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
|
||||
{% load helpers %}
|
||||
<label class="label" style="color: {{ record.color|fgcolor }}; background-color: #{{ record.color }}">{{ record }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
@@ -180,7 +180,7 @@ CABLE_TERMINATION_PARENT = """
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }}{{ record.length_unit }}{% else %}—{% endif %}
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ class ConsoleConnectionTable(BaseTable):
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsoleConnection
|
||||
model = ConsolePort
|
||||
fields = ('console_server', 'connected_endpoint', 'device', 'name', 'connection_status')
|
||||
|
||||
|
||||
@@ -706,7 +706,7 @@ class PowerConnectionTable(BaseTable):
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerConnection
|
||||
model = PowerPort
|
||||
fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status')
|
||||
|
||||
|
||||
@@ -745,7 +745,7 @@ class InterfaceConnectionTable(BaseTable):
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InterfaceConnection
|
||||
model = Interface
|
||||
fields = (
|
||||
'device_a', 'interface_a', 'description_a', 'device_b', 'interface_b', 'description_b', 'connection_status',
|
||||
)
|
||||
|
||||
@@ -20,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')
|
||||
@@ -121,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')
|
||||
@@ -256,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')
|
||||
@@ -366,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')
|
||||
@@ -474,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')
|
||||
@@ -608,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')
|
||||
@@ -719,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')
|
||||
@@ -820,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')
|
||||
@@ -936,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(
|
||||
@@ -1036,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(
|
||||
@@ -1136,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(
|
||||
@@ -1236,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(
|
||||
@@ -1336,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(
|
||||
@@ -1436,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(
|
||||
@@ -1536,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'
|
||||
@@ -1650,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')
|
||||
@@ -1751,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')
|
||||
@@ -1913,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')
|
||||
@@ -1951,7 +1951,7 @@ class ConsolePortTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cable', 'device', 'id', 'name', 'url']
|
||||
['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_consoleport(self):
|
||||
@@ -2026,7 +2026,7 @@ 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')
|
||||
@@ -2064,7 +2064,7 @@ class ConsoleServerPortTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cable', 'device', 'id', 'name', 'url']
|
||||
['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_consoleserverport(self):
|
||||
@@ -2137,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')
|
||||
@@ -2175,7 +2175,7 @@ class PowerPortTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cable', 'device', 'id', 'name', 'url']
|
||||
['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_powerport(self):
|
||||
@@ -2250,7 +2250,7 @@ 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')
|
||||
@@ -2288,7 +2288,7 @@ class PowerOutletTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cable', 'device', 'id', 'name', 'url']
|
||||
['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_poweroutlet(self):
|
||||
@@ -2361,7 +2361,7 @@ 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')
|
||||
@@ -2425,7 +2425,7 @@ class InterfaceTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['cable', 'device', 'id', 'name', 'url']
|
||||
['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_interface(self):
|
||||
@@ -2560,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')
|
||||
@@ -2683,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')
|
||||
@@ -2799,7 +2799,7 @@ class CableTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super(CableTest, 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')
|
||||
@@ -2940,7 +2940,7 @@ class ConnectionTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super(ConnectionTest, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
self.site = Site.objects.create(
|
||||
name='Test Site 1', slug='test-site-1'
|
||||
@@ -3304,7 +3304,7 @@ 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')
|
||||
@@ -3346,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')
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,
|
||||
@@ -24,11 +25,10 @@ from utilities.views import (
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
from .models import (
|
||||
Cable, ConsoleConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceConnection,
|
||||
InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerConnection, PowerOutlet, PowerOutletTemplate,
|
||||
PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1530,6 +1530,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = ConsolePort
|
||||
model_form = forms.ConsolePortForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1541,6 +1542,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = ConsoleServerPort
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1552,6 +1554,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = PowerPort
|
||||
model_form = forms.PowerPortForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1563,6 +1566,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = PowerOutlet
|
||||
model_form = forms.PowerOutletForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1574,6 +1578,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
form = forms.DeviceBulkAddInterfaceForm
|
||||
model = Interface
|
||||
model_form = forms.InterfaceForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1585,6 +1590,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
form = forms.DeviceBulkAddComponentForm
|
||||
model = DeviceBay
|
||||
model_form = forms.DeviceBayForm
|
||||
filter = filters.DeviceFilter
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -1688,7 +1694,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
#
|
||||
|
||||
class ConsoleConnectionsListView(ObjectListView):
|
||||
queryset = ConsoleConnection.objects.select_related(
|
||||
queryset = ConsolePort.objects.select_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
@@ -1700,9 +1706,25 @@ class ConsoleConnectionsListView(ObjectListView):
|
||||
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 = PowerConnection.objects.select_related(
|
||||
queryset = PowerPort.objects.select_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
@@ -1714,9 +1736,25 @@ class PowerConnectionsListView(ObjectListView):
|
||||
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(
|
||||
queryset = Interface.objects.select_related(
|
||||
'device', 'cable', '_connected_interface__device'
|
||||
).filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
@@ -1730,6 +1768,22 @@ class InterfaceConnectionsListView(ObjectListView):
|
||||
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
|
||||
|
||||
@@ -28,9 +28,10 @@ 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'])
|
||||
if 'obj_type' in self.fields:
|
||||
order_content_types(self.fields['obj_type'])
|
||||
|
||||
|
||||
@admin.register(Webhook, site=admin_site)
|
||||
@@ -56,7 +57,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'])
|
||||
|
||||
@@ -96,7 +97,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'])
|
||||
|
||||
@@ -105,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:
|
||||
|
||||
@@ -137,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:
|
||||
@@ -152,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:
|
||||
|
||||
@@ -108,7 +108,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
|
||||
# Enforce model validation
|
||||
super(ImageAttachmentSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -50,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,
|
||||
@@ -59,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')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -17,7 +17,7 @@ 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):
|
||||
|
||||
@@ -31,12 +31,12 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
# Treat 0 as None
|
||||
if int(value) == 0:
|
||||
return queryset.exclude(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__field__name=self.field_name,
|
||||
)
|
||||
# Match on exact CustomFieldChoice PK
|
||||
else:
|
||||
return queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__field__name=self.field_name,
|
||||
custom_field_values__serialized_value=value,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -45,12 +45,12 @@ class CustomFieldFilter(django_filters.Filter):
|
||||
# Apply the assigned filter logic (exact or loose)
|
||||
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__field__name=self.field_name,
|
||||
custom_field_values__serialized_value=value
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(
|
||||
custom_field_values__field__name=self.name,
|
||||
custom_field_values__field__name=self.field_name,
|
||||
custom_field_values__serialized_value__icontains=value
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ 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)
|
||||
|
||||
@@ -11,7 +11,7 @@ from taggit.models import Tag
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
|
||||
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
|
||||
FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
|
||||
)
|
||||
from .constants import (
|
||||
@@ -102,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 = []
|
||||
@@ -138,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:
|
||||
@@ -152,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)
|
||||
@@ -175,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()
|
||||
@@ -193,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)
|
||||
@@ -208,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'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -249,7 +254,9 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['description']
|
||||
nullable_fields = [
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
||||
@@ -291,28 +298,29 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ImageAttachment
|
||||
fields = ['name', 'image']
|
||||
fields = [
|
||||
'name', 'image',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
model = ObjectChange
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
# TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
|
||||
time_0 = forms.DateTimeField(
|
||||
time_after = forms.DateTimeField(
|
||||
label='After',
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
|
||||
)
|
||||
)
|
||||
time_1 = forms.DateTimeField(
|
||||
time_before = forms.DateTimeField(
|
||||
label='Before',
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -327,3 +335,9 @@ class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=User.objects.order_by('username'),
|
||||
required=False
|
||||
)
|
||||
changed_object_type = forms.ModelChoiceField(
|
||||
queryset=ContentType.objects.order_by('model'),
|
||||
required=False,
|
||||
widget=ContentTypeSelect(),
|
||||
label='Object Type'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.14 on 2018-07-31 02:19
|
||||
import re
|
||||
from distutils.version import StrictVersion
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
@@ -17,13 +15,14 @@ def verify_postgresql_version(apps, schema_editor):
|
||||
"""
|
||||
Verify that PostgreSQL is version 9.4 or higher.
|
||||
"""
|
||||
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
|
||||
DB_MINIMUM_VERSION = 90400 # 9.4.0
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
row = cursor.fetchone()
|
||||
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||
pg_version = connection.pg_version
|
||||
|
||||
if pg_version < DB_MINIMUM_VERSION:
|
||||
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
|
||||
|
||||
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
|
||||
except OperationalError:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.4 on 2017-09-26 21:25
|
||||
from distutils.version import StrictVersion
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
@@ -14,13 +12,14 @@ def verify_postgresql_version(apps, schema_editor):
|
||||
"""
|
||||
Verify that PostgreSQL is version 9.4 or higher.
|
||||
"""
|
||||
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
|
||||
DB_MINIMUM_VERSION = 90400 # 9.4.0
|
||||
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT VERSION()")
|
||||
row = cursor.fetchone()
|
||||
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
|
||||
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
|
||||
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
|
||||
pg_version = connection.pg_version
|
||||
|
||||
if pg_version < DB_MINIMUM_VERSION:
|
||||
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
|
||||
|
||||
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
|
||||
except OperationalError:
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||
from utilities.utils import foreground_color
|
||||
from utilities.utils import deepmerge, foreground_color
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
|
||||
@@ -261,7 +261,7 @@ 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)
|
||||
|
||||
|
||||
class CustomFieldChoice(models.Model):
|
||||
@@ -293,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()
|
||||
|
||||
|
||||
@@ -603,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)
|
||||
@@ -717,11 +717,11 @@ class ConfigContextModel(models.Model):
|
||||
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
|
||||
data = OrderedDict()
|
||||
for context in ConfigContext.objects.get_for_object(self):
|
||||
data.update(context.data)
|
||||
data = deepmerge(data, context.data)
|
||||
|
||||
# If the object has local config context data defined, that data overwrites all rendered data
|
||||
# If the object has local config context data defined, merge it last
|
||||
if self.local_context_data is not None:
|
||||
data.update(self.local_context_data)
|
||||
data = deepmerge(data, self.local_context_data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -841,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])
|
||||
|
||||
@@ -14,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'
|
||||
@@ -118,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(
|
||||
@@ -225,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')
|
||||
@@ -316,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',
|
||||
|
||||
@@ -101,7 +101,7 @@ class CustomFieldAPITest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super(CustomFieldAPITest, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class TaggedItemTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super(TaggedItemTest, self).setUp()
|
||||
super().setUp()
|
||||
|
||||
def test_create_tagged_item(self):
|
||||
|
||||
|
||||
@@ -7,19 +7,19 @@ urlpatterns = [
|
||||
|
||||
# Tags
|
||||
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
|
||||
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
|
||||
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
|
||||
|
||||
# Config contexts
|
||||
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
|
||||
url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
|
||||
url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
||||
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
||||
url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
|
||||
url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
|
||||
url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
|
||||
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
||||
|
||||
# Image attachments
|
||||
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
|
||||
@@ -82,7 +82,7 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
|
||||
|
||||
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuittype'
|
||||
permission_required = 'taggit.delete_tag'
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('taggit_taggeditem_items')
|
||||
).order_by(
|
||||
|
||||
@@ -45,7 +45,7 @@ def enqueue_webhooks(instance, action):
|
||||
"extras.webhooks_worker.process_webhook",
|
||||
webhook,
|
||||
serializer.data,
|
||||
instance.__class__,
|
||||
instance._meta.model_name,
|
||||
action,
|
||||
str(datetime.datetime.now())
|
||||
)
|
||||
|
||||
@@ -10,14 +10,14 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, data, model_class, event, timestamp):
|
||||
def process_webhook(webhook, data, model_name, event, timestamp):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
payload = {
|
||||
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
|
||||
'timestamp': timestamp,
|
||||
'model': model_class._meta.model_name,
|
||||
'model': model_name,
|
||||
'data': data
|
||||
}
|
||||
headers = {
|
||||
|
||||
@@ -87,7 +87,7 @@ class VLANGroupSerializer(ValidatedModelSerializer):
|
||||
validator(data)
|
||||
|
||||
# Enforce model validation
|
||||
super(VLANGroupSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -118,7 +118,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
validator(data)
|
||||
|
||||
# Enforce model validation
|
||||
super(VLANSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -40,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):
|
||||
|
||||
@@ -112,6 +112,10 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
prefix = django_filters.CharFilter(
|
||||
method='filter_prefix',
|
||||
label='Prefix',
|
||||
)
|
||||
within = django_filters.CharFilter(
|
||||
method='search_within',
|
||||
label='Within prefix',
|
||||
@@ -197,6 +201,15 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def filter_prefix(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(value).cidr)
|
||||
return queryset.filter(prefix=query)
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def search_within(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
|
||||
@@ -34,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",
|
||||
}
|
||||
@@ -67,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 --'
|
||||
)
|
||||
@@ -97,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):
|
||||
@@ -112,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'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -124,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",
|
||||
@@ -152,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',
|
||||
@@ -181,7 +236,9 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['name', 'slug']
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
]
|
||||
|
||||
|
||||
class RoleCSVForm(forms.ModelForm):
|
||||
@@ -205,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(
|
||||
@@ -217,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(
|
||||
@@ -229,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)
|
||||
@@ -250,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'
|
||||
|
||||
@@ -311,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')
|
||||
@@ -345,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 --'
|
||||
)
|
||||
@@ -384,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'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -410,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(
|
||||
@@ -423,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(
|
||||
@@ -462,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
|
||||
@@ -483,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'
|
||||
|
||||
@@ -505,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'):
|
||||
@@ -515,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']:
|
||||
@@ -538,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'
|
||||
|
||||
|
||||
@@ -612,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')
|
||||
@@ -662,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']:
|
||||
@@ -677,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 --'
|
||||
)
|
||||
@@ -735,7 +916,9 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['site', 'name', 'slug']
|
||||
fields = [
|
||||
'site', 'name', 'slug',
|
||||
]
|
||||
|
||||
|
||||
class VLANGroupCSVForm(forms.ModelForm):
|
||||
@@ -760,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 --'
|
||||
)
|
||||
@@ -775,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(
|
||||
@@ -793,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)",
|
||||
@@ -850,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')
|
||||
@@ -862,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 --'
|
||||
)
|
||||
@@ -905,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 --'
|
||||
)
|
||||
@@ -916,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:
|
||||
@@ -960,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',
|
||||
]
|
||||
|
||||
@@ -63,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])
|
||||
@@ -198,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 (
|
||||
@@ -369,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 (
|
||||
@@ -484,7 +484,7 @@ 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')
|
||||
|
||||
|
||||
@@ -605,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):
|
||||
|
||||
@@ -773,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])
|
||||
@@ -812,7 +812,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
)
|
||||
).distinct()
|
||||
|
||||
|
||||
class Service(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
@@ -430,7 +430,7 @@ class VLANDetailTable(VLANTable):
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
|
||||
name = tables.Column(verbose_name='Interface')
|
||||
name = tables.LinkColumn(verbose_name='Interface')
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
orderable=False
|
||||
@@ -464,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)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -12,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')
|
||||
@@ -113,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')
|
||||
@@ -214,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')
|
||||
@@ -317,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')
|
||||
@@ -418,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')
|
||||
@@ -657,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'))
|
||||
@@ -756,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')
|
||||
@@ -857,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')
|
||||
@@ -958,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')
|
||||
|
||||
@@ -715,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):
|
||||
|
||||
|
||||
@@ -58,14 +58,14 @@ class TokenPermissions(DjangoModelPermissions):
|
||||
def __init__(self):
|
||||
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
|
||||
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)
|
||||
|
||||
|
||||
#
|
||||
@@ -132,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):
|
||||
|
||||
@@ -140,7 +140,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
if not self.limit:
|
||||
return None
|
||||
|
||||
return super(OptionalLimitOffsetPagination, self).get_previous_link()
|
||||
return super().get_previous_link()
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -7,7 +7,7 @@ import warnings
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
# Check for Python 3.5+
|
||||
# Django 2.1 requires 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])
|
||||
@@ -21,7 +21,8 @@ except ImportError:
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
|
||||
VERSION = '2.5-beta2'
|
||||
|
||||
VERSION = '2.5.2'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -247,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
|
||||
@@ -287,9 +288,12 @@ RQ_QUEUES = {
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullableBooleanFieldInspector',
|
||||
'utilities.custom_inspectors.CustomChoiceFieldInspector',
|
||||
'utilities.custom_inspectors.TagListFieldInspector',
|
||||
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
|
||||
'drf_yasg.inspectors.CamelCaseJSONFilter',
|
||||
'drf_yasg.inspectors.ReferencingSerializerInspector',
|
||||
'drf_yasg.inspectors.RelatedFieldInspector',
|
||||
|
||||
@@ -100,7 +100,7 @@ $(document).ready(function() {
|
||||
} 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');
|
||||
rendered_url = rendered_url.replace(match[0], 'null');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ $(document).ready(function() {
|
||||
source: function(request, response) {
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: search_field.attr('data-source'),
|
||||
url: search_field.attr('data-source') + '?brief=1',
|
||||
data: search_key + '=' + request.term,
|
||||
success: function(data) {
|
||||
var choices = [];
|
||||
@@ -49,7 +49,7 @@ $(document).ready(function() {
|
||||
// Disable parent selection fields
|
||||
// $('select[filter-for="' + real_field.attr('name') + '"]').val('');
|
||||
},
|
||||
minLength: 4,
|
||||
minLength: 3,
|
||||
delay: 500
|
||||
});
|
||||
|
||||
|
||||
@@ -21,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'):
|
||||
|
||||
@@ -49,6 +49,6 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
validator(data)
|
||||
|
||||
# Enforce model validation
|
||||
super(SecretSerializer, self).validate(data)
|
||||
super().validate(data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -56,14 +56,14 @@ class SecretViewSet(ModelViewSet):
|
||||
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:
|
||||
|
||||
|
||||
@@ -39,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):
|
||||
@@ -62,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,
|
||||
@@ -70,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:
|
||||
@@ -122,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'
|
||||
)
|
||||
|
||||
@@ -169,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'
|
||||
)
|
||||
|
||||
@@ -85,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
|
||||
@@ -125,7 +125,7 @@ class UserKey(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
super(UserKey, self).clean()
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -138,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):
|
||||
|
||||
@@ -148,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):
|
||||
"""
|
||||
@@ -230,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):
|
||||
|
||||
@@ -356,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:
|
||||
|
||||
@@ -51,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')
|
||||
@@ -152,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()
|
||||
@@ -294,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()
|
||||
|
||||
@@ -228,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.")
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> ·
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> ·
|
||||
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% endfor %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<h3>{% block title %}Circuit {{ obj.circuit }} - {{ form.term_side.value }} Side{% endblock %}</h3>
|
||||
<h3>{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}</h3>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if perms.circuits.add_cable %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
|
||||
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</h4>
|
||||
{{ cable.get_status_display }}<br />
|
||||
{{ cable.get_type_display|default:"" }}
|
||||
{% if cable.length %}- {{ cable.length }}{{ cable.length_unit }}{% endif %}
|
||||
{% if cable.length %}- {{ cable.length }}{{ cable.get_length_unit_display }}{% endif %}
|
||||
<span class="label color-block center-block" style="background-color: #{{ cable.color }}"> </span>
|
||||
{% else %}
|
||||
<h4 class="text-muted">No Cable</h4>
|
||||
|
||||
@@ -62,6 +62,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Virtualization</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tenancy</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -22,13 +22,20 @@
|
||||
{% for iface in interfaces %}
|
||||
<tr id="{{ iface.name }}">
|
||||
<td>{{ iface }}</td>
|
||||
{% if iface.connected_endpoint %}
|
||||
{% if iface.connected_endpoint.device %}
|
||||
<td class="configured_device" data="{{ iface.connected_endpoint.device }}">
|
||||
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
|
||||
</td>
|
||||
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
|
||||
<span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span>
|
||||
</td>
|
||||
{% elif iface.connected_endpoint.circuit %}
|
||||
{% with circuit=iface.connected_endpoint.circuit %}
|
||||
<td colspan="2">
|
||||
<i class="fa fa-fw fa-globe" title="Circuit"></i>
|
||||
<a href="{{ circuit.get_absolute_url }}">{{ circuit.provider }} {{ circuit }}</a>
|
||||
</td>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<td colspan="2">None</td>
|
||||
{% endif %}
|
||||
|
||||
@@ -70,7 +70,10 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model Name</td>
|
||||
<td>{{ devicetype.model }}</td>
|
||||
<td>
|
||||
{{ devicetype.model }}<br/>
|
||||
<small class="text-muted">{{ devicetype.slug }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Part Number</td>
|
||||
@@ -160,7 +163,7 @@
|
||||
{% if devicetype.interface_templates.exists %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfacaes' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
|
||||
{% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<tr>
|
||||
<td>Circuit</td>
|
||||
<td>
|
||||
<a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> (Side {{ termination.term_side }})
|
||||
<a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> ({{ termination }})
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% if end.device %}
|
||||
<strong><a href="{{ end.device.get_absolute_url }}">{{ end.device }}</a></strong>
|
||||
{% else %}
|
||||
<strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong>
|
||||
<strong><a href="{{ end.circuit.provider.get_absolute_url }}">{{ end.circuit.provider }}</a></strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-body text-center">
|
||||
@@ -21,7 +21,8 @@
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{# Circuit termination #}
|
||||
<strong>Side {{ end.term_side }}</strong>
|
||||
<strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong><br/>
|
||||
{{ end }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +75,16 @@
|
||||
{% elif iface.connected_endpoint.name %}
|
||||
{# Connected to an Interface #}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
|
||||
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">
|
||||
{{ iface.connected_endpoint.device }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}"><span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span></a>
|
||||
<a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}">
|
||||
<span title="{{ iface.connected_endpoint.get_form_factor_display }}">
|
||||
{{ iface.connected_endpoint }}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
{% elif iface.connected_endpoint.term_side %}
|
||||
{# Connected to a CircuitTermination #}
|
||||
@@ -86,22 +92,38 @@
|
||||
{% if peer_termination %}
|
||||
{% if peer_termination.connected_endpoint %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">{{ peer_termination.connected_endpoint.device }}</a><br/>
|
||||
<small>via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a></small>
|
||||
<a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">
|
||||
{{ peer_termination.connected_endpoint.device }}
|
||||
</a><br/>
|
||||
<small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
|
||||
<a href="{{ iface.connected_endpoint.circuit.get_absolure_url }}">
|
||||
{{ iface.connected_endpoint.circuit.provider }}
|
||||
{{ iface.connected_endpoint.circuit }}
|
||||
</a>
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{{ peer_termination.connected_endpoint }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2">
|
||||
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>
|
||||
via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
|
||||
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">
|
||||
{{ peer_termination.site }}
|
||||
</a>
|
||||
via <i class="fa fa-fw fa-globe" title="Circuit"></i>
|
||||
<a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
|
||||
{{ iface.connected_endpoint.circuit.provider }}
|
||||
{{ iface.connected_endpoint.circuit }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="2">
|
||||
<i class="fa fa-fw fa-globe" title="Circuit"></i>
|
||||
<a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
|
||||
<a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
|
||||
{{ iface.connected_endpoint.circuit.provider }}
|
||||
{{ iface.connected_endpoint.circuit }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
{% ifequal u.device.face face_id %}
|
||||
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
|
||||
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
|
||||
{{ u.device.name|default:u.device.device_role }}
|
||||
{{ u.device }}
|
||||
{% if u.device.devicebay_count %}
|
||||
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ u.device.name|default:u.device.device_role }}</span>
|
||||
<span>{{ u.device }}</span>
|
||||
{% endifequal %}
|
||||
</li>
|
||||
{% else %}
|
||||
|
||||
@@ -163,6 +163,10 @@
|
||||
</tr>
|
||||
{% elif connected_circuittermination %}
|
||||
{% with ct=connected_circuittermination %}
|
||||
<tr>
|
||||
<td>Provider</td>
|
||||
<td><a href="{{ ct.circuit.provider.get_absolute_url }}">{{ ct.circuit.provider }}</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Circuit</td>
|
||||
<td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
<td>Outer Width</td>
|
||||
<td>
|
||||
{% if rack.outer_width %}
|
||||
<span>{{ rack.outer_width }}{{ rack.outer_unit }}</span>
|
||||
<span>{{ rack.outer_width }} {{ rack.get_outer_unit_display }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
@@ -168,7 +168,7 @@
|
||||
<td>Outer Depth</td>
|
||||
<td>
|
||||
{% if rack.outer_depth %}
|
||||
<span>{{ rack.outer_depth }}{{ rack.outer_unit }}</span>
|
||||
<span>{{ rack.outer_depth }} {{ rack.get_outer_unit_display }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Add {{ component_name|title }}</h1>
|
||||
<h1>{% block title %}Add {{ model_name|title }}{% endblock %}</h1>
|
||||
<p>{{ table.rows|length }} {{ parent_model_name }} selected</p>
|
||||
<form action="." method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if request.POST.return_url %}
|
||||
@@ -27,7 +28,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>{{ component_name|title }} to Add</strong></div>
|
||||
<div class="panel-heading"><strong>{{ model_name|title }} to Add</strong></div>
|
||||
<div class="panel-body">
|
||||
{% for field in form.visible_fields %}
|
||||
{% render_field field %}
|
||||
|
||||
@@ -18,7 +18,9 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = TenantGroup
|
||||
fields = ['name', 'slug']
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
]
|
||||
|
||||
|
||||
class TenantGroupCSVForm(forms.ModelForm):
|
||||
@@ -39,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):
|
||||
@@ -68,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 --'
|
||||
)
|
||||
@@ -94,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(
|
||||
@@ -117,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)
|
||||
|
||||
@@ -9,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')
|
||||
@@ -110,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')
|
||||
|
||||
@@ -8,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'] = ''
|
||||
@@ -19,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]'
|
||||
}
|
||||
|
||||
@@ -48,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.
|
||||
|
||||
@@ -132,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)
|
||||
|
||||
@@ -66,7 +66,7 @@ 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):
|
||||
if obj is '':
|
||||
@@ -78,17 +78,26 @@ class ChoiceField(Field):
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
|
||||
# Provide an explicit error message if the request is trying to write a dict
|
||||
if type(data) is dict:
|
||||
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.')
|
||||
|
||||
# Check for string representations of boolean/integer 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
|
||||
data = True
|
||||
elif data.lower() == 'false':
|
||||
data = False
|
||||
else:
|
||||
try:
|
||||
data = int(data)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if data not in self._choices:
|
||||
raise ValidationError("{} is not a valid choice.".format(data))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -130,7 +139,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
|
||||
@@ -206,7 +215,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):
|
||||
|
||||
@@ -230,7 +239,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()
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
|
||||
from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
from taggit_serializer.serializers import TagListSerializerField
|
||||
|
||||
from extras.api.customfields import CustomFieldsSerializer
|
||||
from utilities.api import ChoiceField
|
||||
from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
|
||||
|
||||
|
||||
class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
|
||||
def get_request_serializer(self):
|
||||
serializer = super().get_request_serializer()
|
||||
|
||||
if serializer is not None and self.method in self.implicit_body_methods:
|
||||
properties = {}
|
||||
for child_name, child in serializer.fields.items():
|
||||
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
|
||||
properties[child_name] = None
|
||||
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
|
||||
properties[child_name] = None
|
||||
|
||||
if properties:
|
||||
writable_class = type('Writable' + type(serializer).__name__, (type(serializer),), properties)
|
||||
serializer = writable_class()
|
||||
|
||||
return serializer
|
||||
|
||||
|
||||
class SerializedPKRelatedFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
if isinstance(field, SerializedPKRelatedField):
|
||||
return self.probe_field_inspectors(field.serializer(), ChildSwaggerType, use_references)
|
||||
|
||||
return NotHandled
|
||||
|
||||
|
||||
class TagListFieldInspector(FieldInspector):
|
||||
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
|
||||
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
|
||||
if isinstance(field, TagListSerializerField):
|
||||
child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references)
|
||||
return SwaggerType(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=child_schema,
|
||||
)
|
||||
|
||||
return NotHandled
|
||||
|
||||
|
||||
class CustomChoiceFieldInspector(FieldInspector):
|
||||
|
||||
@@ -28,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)
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
|
||||
@@ -34,4 +34,4 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
|
||||
kwargs.setdefault('conjoined', True)
|
||||
kwargs.setdefault('queryset', Tag.objects.all())
|
||||
|
||||
super(TagFilter, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -10,34 +10,9 @@ 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})\]'
|
||||
@@ -193,7 +168,7 @@ class ColorSelect(forms.Select):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(COLOR_CHOICES)
|
||||
super(ColorSelect, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
|
||||
@@ -202,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 = (
|
||||
@@ -242,17 +217,17 @@ class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@@ -279,7 +254,7 @@ class APISelect(SelectWithDisabled):
|
||||
**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
|
||||
@@ -308,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,
|
||||
@@ -336,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:
|
||||
@@ -382,12 +357,12 @@ class CSVChoiceField(forms.ChoiceField):
|
||||
"""
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super(CSVChoiceField, self).__init__(choices=choices, *args, **kwargs)
|
||||
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:
|
||||
@@ -401,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.<br />' \
|
||||
'Mixed cases and types within a single range are not supported.<br />' \
|
||||
@@ -421,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.<br />'\
|
||||
'Example: <code>192.0.2.[1,5,100-254]/24</code>'
|
||||
@@ -450,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):
|
||||
@@ -490,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):
|
||||
@@ -499,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):
|
||||
@@ -509,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
|
||||
|
||||
|
||||
@@ -536,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
|
||||
@@ -582,7 +557,7 @@ class AnnotatedMultipleChoiceField(forms.MultipleChoiceField):
|
||||
self.annotate_field = annotate_field
|
||||
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):
|
||||
@@ -599,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 <a href="https://json.org/">JSON</a> format.'
|
||||
self.widget.attrs['placeholder'] = ''
|
||||
@@ -621,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
|
||||
@@ -642,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():
|
||||
|
||||
@@ -691,7 +666,7 @@ 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 {}
|
||||
@@ -702,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 = []
|
||||
|
||||
@@ -15,7 +15,7 @@ class NaturalOrderingManager(Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
queryset = super(NaturalOrderingManager, self).get_queryset()
|
||||
queryset = super().get_queryset()
|
||||
|
||||
db_table = self.model._meta.db_table
|
||||
db_field = self.natural_order_field
|
||||
|
||||
@@ -7,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)
|
||||
|
||||
@@ -5,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
|
||||
@@ -28,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)
|
||||
|
||||
@@ -7,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:
|
||||
@@ -26,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):
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
|
||||
from utilities.forms import unpack_grouped_choices
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
|
||||
register = template.Library()
|
||||
@@ -152,6 +154,17 @@ def tzoffset(value):
|
||||
return datetime.datetime.now(value).strftime('%z')
|
||||
|
||||
|
||||
@register.filter()
|
||||
def fgcolor(value):
|
||||
"""
|
||||
Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format.
|
||||
"""
|
||||
value = value.lower().strip('#')
|
||||
if not re.match('^[0-9a-f]{6}$', value):
|
||||
return ''
|
||||
return '#{}'.format(foreground_color(value))
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
89
netbox/utilities/tests/test_utils.py
Normal file
89
netbox/utilities/tests/test_utils.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
class DeepMergeTest(TestCase):
|
||||
"""
|
||||
Validate the behavior of the deepmerge() utility.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
return
|
||||
|
||||
def test_deepmerge(self):
|
||||
|
||||
dict1 = {
|
||||
'active': True,
|
||||
'foo': 123,
|
||||
'fruits': {
|
||||
'orange': 1,
|
||||
'apple': 2,
|
||||
'pear': 3,
|
||||
},
|
||||
'vegetables': None,
|
||||
'dairy': {
|
||||
'milk': 1,
|
||||
'cheese': 2,
|
||||
},
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 10,
|
||||
'b': 20,
|
||||
'c': 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dict2 = {
|
||||
'active': False,
|
||||
'bar': 456,
|
||||
'fruits': {
|
||||
'banana': 4,
|
||||
'grape': 5,
|
||||
},
|
||||
'vegetables': {
|
||||
'celery': 1,
|
||||
'carrots': 2,
|
||||
'corn': 3,
|
||||
},
|
||||
'dairy': None,
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 100,
|
||||
'd': 40,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged = {
|
||||
'active': False,
|
||||
'foo': 123,
|
||||
'bar': 456,
|
||||
'fruits': {
|
||||
'orange': 1,
|
||||
'apple': 2,
|
||||
'pear': 3,
|
||||
'banana': 4,
|
||||
'grape': 5,
|
||||
},
|
||||
'vegetables': {
|
||||
'celery': 1,
|
||||
'carrots': 2,
|
||||
'corn': 3,
|
||||
},
|
||||
'dairy': None,
|
||||
'deepnesting': {
|
||||
'foo': {
|
||||
'a': 100,
|
||||
'b': 20,
|
||||
'c': 30,
|
||||
'd': 40,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
deepmerge(dict1, dict2),
|
||||
merged
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user