mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-01 22:53:39 +01:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b29a5511df | ||
|
|
49e77841e0 | ||
|
|
daf6c8e327 | ||
|
|
9f8068e8d1 | ||
|
|
0b705553a5 | ||
|
|
a799094227 | ||
|
|
2f064cdfd1 | ||
|
|
6c28182dd3 | ||
|
|
3cb8c5db28 | ||
|
|
251abdb4dd | ||
|
|
726e4df54b | ||
|
|
bd32a6ac8e | ||
|
|
27d7400c36 | ||
|
|
53e52aeaa8 | ||
|
|
3ad773beb3 | ||
|
|
be91235858 | ||
|
|
95fc0bbc94 | ||
|
|
9dad7e4daf | ||
|
|
d08ed9fe5f | ||
|
|
82210cc116 | ||
|
|
94d3e76517 | ||
|
|
3f72492a59 | ||
|
|
b7aa44837f | ||
|
|
7b7afd3e7b | ||
|
|
9c2514fce4 | ||
|
|
e04402ed57 | ||
|
|
3eda8d8482 | ||
|
|
79f2f03fb2 | ||
|
|
e5d7578663 | ||
|
|
773fd47ca6 | ||
|
|
8f1acb700d | ||
|
|
7b1335825b | ||
|
|
11e2200acf | ||
|
|
f5356b84f6 | ||
|
|
1bf100ba15 | ||
|
|
7614f423e5 | ||
|
|
318c8b85e9 | ||
|
|
7085fe77da | ||
|
|
2e20d7f02b | ||
|
|
831065b5a1 | ||
|
|
b97167e841 | ||
|
|
19bacc9e23 | ||
|
|
61b61b1bc0 | ||
|
|
7c3318df92 | ||
|
|
d0b85586b9 | ||
|
|
cef0d168a5 | ||
|
|
3a192223a3 | ||
|
|
288a1d23e5 | ||
|
|
7c05db8e2f | ||
|
|
b7c0e8b71f | ||
|
|
a5ec0ee277 | ||
|
|
d528614cbf | ||
|
|
b5e8157700 | ||
|
|
24d6941cc4 | ||
|
|
0a62f75a40 | ||
|
|
a090955918 | ||
|
|
dfdeac4968 | ||
|
|
e84f2e3ad2 | ||
|
|
98ca4f5b5a | ||
|
|
87779b7b88 | ||
|
|
b56cae24c5 | ||
|
|
d48a68317d | ||
|
|
b07e88869a | ||
|
|
94bd27bcf5 | ||
|
|
090df05193 | ||
|
|
2c161c01c1 | ||
|
|
fc5a23cc88 | ||
|
|
73f2f9fc63 | ||
|
|
eb4b4a6c8d | ||
|
|
39430e01de | ||
|
|
96015aa590 | ||
|
|
c1720505f3 | ||
|
|
5c338a90a1 | ||
|
|
79cee12b1e | ||
|
|
aa5c42683a | ||
|
|
9c6938e7ae | ||
|
|
811c21ec7e | ||
|
|
84c14aadc7 | ||
|
|
f1f0d9cd0d | ||
|
|
e16942dea5 | ||
|
|
12efcec3b0 | ||
|
|
a7b6c40596 | ||
|
|
b95773938d | ||
|
|
6898ae7106 | ||
|
|
1a4f8c5422 | ||
|
|
66c4d23119 | ||
|
|
d66fc8f661 | ||
|
|
031876964f | ||
|
|
c63766c4c6 | ||
|
|
af6237e12e | ||
|
|
00328226ec | ||
|
|
b31ba4e9d2 | ||
|
|
4be5d3f9e9 | ||
|
|
53154746fc | ||
|
|
2f4c1b6e8f | ||
|
|
045ec7d3a0 | ||
|
|
b73db750e5 | ||
|
|
3f766ffea8 | ||
|
|
f28761202f | ||
|
|
6d1f07df05 | ||
|
|
eb9f2b36ab | ||
|
|
2bd29127dc | ||
|
|
3eef6363fd | ||
|
|
d451f30bfc | ||
|
|
105956f8e6 | ||
|
|
39256afb67 | ||
|
|
69aaf28b9c | ||
|
|
b806220074 | ||
|
|
d2bdf4e822 | ||
|
|
3ab5682e7a | ||
|
|
c0010ec100 | ||
|
|
6897c5fadd | ||
|
|
745aa23ed6 | ||
|
|
9089f5cf67 | ||
|
|
dd79aae137 | ||
|
|
26e470f521 | ||
|
|
a34c8b80e5 | ||
|
|
854a12982f | ||
|
|
cf173d4f50 | ||
|
|
7041486b93 | ||
|
|
548a8c3be3 | ||
|
|
087a018faf | ||
|
|
e09024e86f | ||
|
|
1757102536 | ||
|
|
c262af550d | ||
|
|
d9c6609b24 | ||
|
|
339bcb89bb | ||
|
|
b5884a5b54 | ||
|
|
c818d63043 | ||
|
|
c9c537a1b9 | ||
|
|
1be748b479 | ||
|
|
376c776520 | ||
|
|
a1f271d7d9 | ||
|
|
724997cb48 | ||
|
|
f3fe3f9a18 | ||
|
|
357a5d1e65 | ||
|
|
460e3fd5d6 | ||
|
|
257c0afdb5 | ||
|
|
ed3bc7cdcc | ||
|
|
bd181ac84f | ||
|
|
d1f5988db7 | ||
|
|
a5b99e7148 | ||
|
|
114500e7f4 | ||
|
|
d9f178e315 | ||
|
|
7337630704 | ||
|
|
0fdd081869 | ||
|
|
a9761e8dd2 | ||
|
|
1f1a05dc67 | ||
|
|
14b065cf5f | ||
|
|
47c3a20fda | ||
|
|
19c984bdab |
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -13,11 +13,8 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: >
|
||||
What version of NetBox are you currently running? (If you don't have access to the most
|
||||
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
|
||||
before opening a bug report to see if your issue has already been addressed.)
|
||||
placeholder: v3.0.4
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.0.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.0.4
|
||||
placeholder: v3.0.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature.
|
||||
(However, note that comments with no substance other than a "+1" will be
|
||||
deleted. Please use GitHub's reactions feature to indicate your support.)
|
||||
|
||||
* Due to a large backlog of feature requests, we are not currently accepting
|
||||
any proposals which substantially extend NetBox's functionality beyond its
|
||||
current feature set. This includes the introduction of any new views or models
|
||||
which have not already been proposed in an existing feature request.
|
||||
|
||||
* Before filing a new feature request, consider raising your idea on the
|
||||
mailing list first. Feedback you receive there will help validate and shape the
|
||||
proposed feature before filing a formal issue.
|
||||
* Before filing a new feature request, consider raising your idea in a
|
||||
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
|
||||
first. Feedback you receive there will help validate and shape the proposed
|
||||
feature before filing a formal issue.
|
||||
|
||||
* Good feature requests are very narrowly defined. Be sure to thoroughly
|
||||
describe the functionality and data model(s) being proposed. The more effort
|
||||
|
||||
@@ -5,6 +5,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
* Clearing expired authentication sessions from the database
|
||||
* Deleting changelog records older than the configured [retention time](../configuration/optional-settings.md#changelog_retention)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be copied into your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
The `housekeeping` command can also be run manually at any time: Running the command outside of scheduled execution times will not interfere with its operation.
|
||||
```shell
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
!!! note
|
||||
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
|
||||
|
||||
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.
|
||||
|
||||
@@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t
|
||||
---
|
||||
|
||||
{!models/dcim/cable.md!}
|
||||
|
||||
In the example below, three individual cables comprise a path between devices A and D:
|
||||
|
||||

|
||||
|
||||
Traced from Interface 1 on Device A, NetBox will show the following path:
|
||||
|
||||
* Cable 1: Interface 1 to Front Port 1
|
||||
* Cable 2: Rear Port 1 to Rear Port 2
|
||||
* Cable 3: Front Port 2 to Interface 2
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
|
||||
# Example Power Topology
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{!models/extras/customlink.md!}
|
||||
@@ -45,6 +45,20 @@ Defining script variables is optional: You may create a script with only a `run(
|
||||
|
||||
Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
|
||||
|
||||
By default, scripts within a module are ordered alphabetically in the scripts list page. To return scripts in a specific order, you can define the `script_order` variable at the end of your module. The `script_order` variable is a tuple which contains each Script class in the desired order. Any scripts that are omitted from this list will be listed last.
|
||||
|
||||
```python
|
||||
from extras.scripts import Script
|
||||
|
||||
class MyCustomScript(Script):
|
||||
...
|
||||
|
||||
class AnotherCustomScript(Script):
|
||||
...
|
||||
|
||||
script_order = (MyCustomScript, AnotherCustomScript)
|
||||
```
|
||||
|
||||
## Module Attributes
|
||||
|
||||
### `name`
|
||||
@@ -226,7 +240,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
@@ -245,6 +259,22 @@ http://netbox/api/extras/scripts/example.MyReport/ \
|
||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||
```
|
||||
|
||||
### Via the CLI
|
||||
|
||||
Scripts can be run on the CLI by invoking the management command:
|
||||
|
||||
```
|
||||
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
|
||||
```
|
||||
|
||||
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.
|
||||
|
||||
The optional ``--data "<data>"`` argument is the data to send to the script
|
||||
|
||||
The optional ``--loglevel`` argument is the desired logging level to output to the console.
|
||||
|
||||
The optional ``--commit`` argument will commit any changes in the script to the database.
|
||||
|
||||
## Example
|
||||
|
||||
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
|
||||
|
||||
@@ -21,9 +21,6 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
sudo postgresql-setup --initdb
|
||||
```
|
||||
|
||||
!!! info
|
||||
PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
|
||||
|
||||
CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
|
||||
|
||||
```no-highlight
|
||||
|
||||
@@ -17,8 +17,13 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
!!! warning
|
||||
CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source.
|
||||
|
||||
Once you have Python 3.7 or later installed, install the remaining system packages:
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
|
||||
sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
@@ -259,10 +264,10 @@ python3 manage.py createsuperuser
|
||||
|
||||
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
|
||||
|
||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.2. 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 20.04 and CentOS 8.3. 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 following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
@@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
|
||||
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
|
||||
@@ -111,10 +111,10 @@ sudo systemctl restart netbox netbox-rq
|
||||
|
||||
## Verify Housekeeping Scheduling
|
||||
|
||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
cp /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
@@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also
|
||||
## Tracing Cables
|
||||
|
||||
A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
|
||||
|
||||
In the example below, three individual cables comprise a path between devices A and D:
|
||||
|
||||

|
||||
|
||||
Traced from Interface 1 on Device A, NetBox will show the following path:
|
||||
|
||||
* Cable 1: Interface 1 to Front Port 1
|
||||
* Cable 2: Rear Port 1 to Rear Port 2
|
||||
* Cable 3: Front Port 2 to Interface 2
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Custom Links
|
||||
|
||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
|
||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
||||
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the Netbox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
|
||||
|
||||
For example, you might define a link like this:
|
||||
|
||||
|
||||
@@ -1,5 +1,136 @@
|
||||
# NetBox v3.0
|
||||
|
||||
## v3.0.10 (2021-11-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type
|
||||
* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list
|
||||
* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7564](https://github.com/netbox-community/netbox/issues/7564) - Fix assignment of members to virtual chassis with initial position of zero
|
||||
* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables
|
||||
* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession
|
||||
* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10
|
||||
* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table
|
||||
* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import
|
||||
* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view
|
||||
* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer
|
||||
* [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status
|
||||
* [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table
|
||||
* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form
|
||||
* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models
|
||||
* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries
|
||||
|
||||
---
|
||||
|
||||
## v3.0.9 (2021-11-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6529](https://github.com/netbox-community/netbox/issues/6529) - Introduce the `runscript` management command
|
||||
* [#6930](https://github.com/netbox-community/netbox/issues/6930) - Add an optional "ID" column to all tables
|
||||
* [#7668](https://github.com/netbox-community/netbox/issues/7668) - Add "view elevations" button to location view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7599](https://github.com/netbox-community/netbox/issues/7599) - Improve color mode preference handling
|
||||
* [#7601](https://github.com/netbox-community/netbox/issues/7601) - Correct devices count for locations within global search results
|
||||
* [#7612](https://github.com/netbox-community/netbox/issues/7612) - Strip HTML from custom field descriptions
|
||||
* [#7628](https://github.com/netbox-community/netbox/issues/7628) - Fix `load_yaml` method for custom scripts
|
||||
* [#7643](https://github.com/netbox-community/netbox/issues/7643) - Fix circuit assignment when creating multiple terminations simultaneously
|
||||
* [#7644](https://github.com/netbox-community/netbox/issues/7644) - Prevent inadvertent deletion of prior change records when deleting objects (#7333 revisited)
|
||||
* [#7647](https://github.com/netbox-community/netbox/issues/7647) - Require interface assignment when designating IP address as primary for device/VM during CSV import
|
||||
* [#7664](https://github.com/netbox-community/netbox/issues/7664) - Preserve initial form data when bulk edit validation fails
|
||||
* [#7717](https://github.com/netbox-community/netbox/issues/7717) - Restore missing tags column on IP range table
|
||||
* [#7721](https://github.com/netbox-community/netbox/issues/7721) - Retain pagination preference when `MAX_PAGE_SIZE` is zero
|
||||
|
||||
---
|
||||
|
||||
## v3.0.8 (2021-10-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7551](https://github.com/netbox-community/netbox/issues/7551) - Add UI field to filter interfaces by kind
|
||||
* [#7561](https://github.com/netbox-community/netbox/issues/7561) - Add a utilization column to the IP ranges table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7300](https://github.com/netbox-community/netbox/issues/7300) - Fix incorrect Device LLDP interface row coloring
|
||||
* [#7495](https://github.com/netbox-community/netbox/issues/7495) - Fix navigation UI issue that caused improper element overlap
|
||||
* [#7529](https://github.com/netbox-community/netbox/issues/7529) - Restore horizontal scrolling for tables in narrow viewports
|
||||
* [#7534](https://github.com/netbox-community/netbox/issues/7534) - Avoid exception when utilizing "create and add another" twice in succession
|
||||
* [#7544](https://github.com/netbox-community/netbox/issues/7544) - Fix multi-value filtering of custom field objects
|
||||
* [#7545](https://github.com/netbox-community/netbox/issues/7545) - Fix incorrect display of update/delete events for webhooks
|
||||
* [#7550](https://github.com/netbox-community/netbox/issues/7550) - Fix rendering of UTF8-encoded data in change records
|
||||
* [#7556](https://github.com/netbox-community/netbox/issues/7556) - Fix display of version when new release is available
|
||||
* [#7584](https://github.com/netbox-community/netbox/issues/7584) - Fix alignment of object identifier under object view
|
||||
|
||||
---
|
||||
|
||||
## v3.0.7 (2021-10-08)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6879](https://github.com/netbox-community/netbox/issues/6879) - Improve ability to toggle images/labels in rack elevations
|
||||
* [#7485](https://github.com/netbox-community/netbox/issues/7485) - Add USB micro AB type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7051](https://github.com/netbox-community/netbox/issues/7051) - Fix permissions evaluation and improve error handling for connected device REST API endpoint
|
||||
* [#7471](https://github.com/netbox-community/netbox/issues/7471) - Correct redirect URL when attaching images via "add another" button
|
||||
* [#7474](https://github.com/netbox-community/netbox/issues/7474) - Fix AttributeError exception when rendering a report or custom script
|
||||
* [#7479](https://github.com/netbox-community/netbox/issues/7479) - Fix parent interface choices when bulk editing VM interfaces
|
||||
|
||||
---
|
||||
|
||||
## v3.0.6 (2021-10-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6850](https://github.com/netbox-community/netbox/issues/6850) - Default to current user when creating journal entries via REST API
|
||||
* [#6955](https://github.com/netbox-community/netbox/issues/6955) - Include type, ID, and slug on object view
|
||||
* [#7394](https://github.com/netbox-community/netbox/issues/7394) - Enable filtering cables by termination type & ID in REST API
|
||||
* [#7462](https://github.com/netbox-community/netbox/issues/7462) - Include count of assigned virtual machines under platform view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7442](https://github.com/netbox-community/netbox/issues/7442) - Fix missing actions column on user-configured tables
|
||||
* [#7446](https://github.com/netbox-community/netbox/issues/7446) - Fix exception when viewing a large number of child IPs within a prefix
|
||||
* [#7455](https://github.com/netbox-community/netbox/issues/7455) - Fix site/provider network validation for circuit termination API serializer
|
||||
* [#7459](https://github.com/netbox-community/netbox/issues/7459) - Pre-populate location data when adding a device to a rack
|
||||
* [#7460](https://github.com/netbox-community/netbox/issues/7460) - Fix filtering connections by site ID
|
||||
|
||||
---
|
||||
|
||||
## v3.0.5 (2021-10-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view
|
||||
* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
|
||||
* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
|
||||
* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view
|
||||
* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion
|
||||
* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export
|
||||
* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified
|
||||
* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched
|
||||
* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API
|
||||
* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering
|
||||
* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis
|
||||
* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
|
||||
* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
|
||||
* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
|
||||
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
|
||||
|
||||
---
|
||||
|
||||
## v3.0.4 (2021-09-29)
|
||||
|
||||
### Enhancements
|
||||
@@ -30,6 +161,8 @@
|
||||
* [#7374](https://github.com/netbox-community/netbox/issues/7374) - Add missing `face` parameter to API elevations request when editing device
|
||||
* [#7392](https://github.com/netbox-community/netbox/issues/7392) - Fix "help" links for custom fields, other models
|
||||
|
||||
---
|
||||
|
||||
## v3.0.3 (2021-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -65,7 +65,7 @@ nav:
|
||||
- Customization:
|
||||
- Custom Fields: 'customization/custom-fields.md'
|
||||
- Custom Validation: 'customization/custom-validation.md'
|
||||
- Custom Links: 'customization/custom-links.md'
|
||||
- Custom Links: 'models/extras/customlink.md'
|
||||
- Export Templates: 'customization/export-templates.md'
|
||||
- Custom Scripts: 'customization/custom-scripts.md'
|
||||
- Reports: 'customization/reports.md'
|
||||
|
||||
@@ -3,10 +3,10 @@ from rest_framework import serializers
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
|
||||
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
|
||||
from dcim.api.serializers import CableTerminationSerializer
|
||||
from netbox.api import ChoiceField
|
||||
from netbox.api.serializers import (
|
||||
BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer
|
||||
OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
|
||||
)
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from .nested_serializers import *
|
||||
@@ -90,11 +90,11 @@ class CircuitSerializer(PrimaryModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
|
||||
class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer(required=False)
|
||||
provider_network = NestedProviderNetworkSerializer(required=False)
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
# Cache associated CircuitTerminations
|
||||
termination_a = models.ForeignKey(
|
||||
|
||||
@@ -11,6 +11,7 @@ def update_circuit(instance, **kwargs):
|
||||
When a CircuitTermination has been modified, update its parent Circuit.
|
||||
"""
|
||||
termination_name = f'termination_{instance.term_side.lower()}'
|
||||
instance.circuit.refresh_from_db()
|
||||
setattr(instance.circuit, termination_name, instance)
|
||||
instance.circuit.save()
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class ProviderTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
|
||||
'tags',
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProviderNetworkTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
|
||||
default_columns = ('pk', 'name', 'provider', 'description')
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class CircuitTypeTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CircuitType
|
||||
fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class CircuitTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
verbose_name='Circuit ID'
|
||||
)
|
||||
provider = tables.Column(
|
||||
linkify=True
|
||||
@@ -124,7 +124,7 @@ class CircuitTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -136,14 +136,20 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
SIDE_A = CircuitTerminationSideChoices.SIDE_A
|
||||
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
|
||||
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
|
||||
provider_networks = (
|
||||
ProviderNetwork(provider=provider, name='Provider Network 1'),
|
||||
ProviderNetwork(provider=provider, name='Provider Network 2'),
|
||||
)
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
circuits = (
|
||||
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
|
||||
@@ -153,10 +159,10 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
circuit_terminations = (
|
||||
CircuitTermination(circuit=circuits[0], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[0], site=sites[1], term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[0], term_side=SIDE_A),
|
||||
CircuitTermination(circuit=circuits[1], site=sites[1], term_side=SIDE_Z),
|
||||
CircuitTermination(circuit=circuits[0], term_side=SIDE_A, site=sites[0]),
|
||||
CircuitTermination(circuit=circuits[0], term_side=SIDE_Z, provider_network=provider_networks[0]),
|
||||
CircuitTermination(circuit=circuits[1], term_side=SIDE_A, site=sites[1]),
|
||||
CircuitTermination(circuit=circuits[1], term_side=SIDE_Z, provider_network=provider_networks[1]),
|
||||
)
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@@ -164,13 +170,13 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'circuit': circuits[2].pk,
|
||||
'term_side': SIDE_A,
|
||||
'site': sites[1].pk,
|
||||
'site': sites[0].pk,
|
||||
'port_speed': 200000,
|
||||
},
|
||||
{
|
||||
'circuit': circuits[2].pk,
|
||||
'term_side': SIDE_Z,
|
||||
'site': sites[1].pk,
|
||||
'provider_network': provider_networks[0].pk,
|
||||
'port_speed': 200000,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseForbidden, HttpResponse
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.openapi import Parameter
|
||||
@@ -17,10 +17,10 @@ from dcim import filtersets
|
||||
from dcim.models import *
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related, decode_dict
|
||||
from virtualization.models import VirtualMachine
|
||||
@@ -675,15 +675,25 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
if not peer_device_name or not peer_interface_name:
|
||||
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
|
||||
|
||||
# Determine local interface from peer interface's connection
|
||||
# Determine local endpoint from peer interface's connection
|
||||
peer_device = get_object_or_404(
|
||||
Device.objects.restrict(request.user, 'view'),
|
||||
name=peer_device_name
|
||||
)
|
||||
peer_interface = get_object_or_404(
|
||||
Interface.objects.all(),
|
||||
device__name=peer_device_name,
|
||||
Interface.objects.restrict(request.user, 'view'),
|
||||
device=peer_device,
|
||||
name=peer_interface_name
|
||||
)
|
||||
local_interface = peer_interface.connected_endpoint
|
||||
endpoint = peer_interface.connected_endpoint
|
||||
|
||||
if local_interface is None:
|
||||
return Response()
|
||||
# If an Interface, return the parent device
|
||||
if type(endpoint) is Interface:
|
||||
device = get_object_or_404(
|
||||
Device.objects.restrict(request.user, 'view'),
|
||||
pk=endpoint.device_id
|
||||
)
|
||||
return Response(serializers.DeviceSerializer(device, context={'request': request}).data)
|
||||
|
||||
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
|
||||
# Connected endpoint is none or not an Interface
|
||||
raise Http404
|
||||
|
||||
@@ -185,6 +185,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
TYPE_RJ11 = 'rj-11'
|
||||
TYPE_RJ12 = 'rj-12'
|
||||
TYPE_RJ45 = 'rj-45'
|
||||
TYPE_MINI_DIN_8 = 'mini-din-8'
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_B = 'usb-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
@@ -192,6 +193,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
TYPE_USB_MINI_B = 'usb-mini-b'
|
||||
TYPE_USB_MICRO_A = 'usb-micro-a'
|
||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||
TYPE_USB_MICRO_AB = 'usb-micro-ab'
|
||||
TYPE_OTHER = 'other'
|
||||
|
||||
CHOICES = (
|
||||
@@ -201,6 +203,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
(TYPE_RJ11, 'RJ-11'),
|
||||
(TYPE_RJ12, 'RJ-12'),
|
||||
(TYPE_RJ45, 'RJ-45'),
|
||||
(TYPE_MINI_DIN_8, 'Mini-DIN 8'),
|
||||
)),
|
||||
('USB', (
|
||||
(TYPE_USB_A, 'USB Type A'),
|
||||
@@ -210,6 +213,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_MINI_B, 'USB Mini B'),
|
||||
(TYPE_USB_MICRO_A, 'USB Micro A'),
|
||||
(TYPE_USB_MICRO_B, 'USB Micro B'),
|
||||
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_OTHER, 'Other'),
|
||||
@@ -337,6 +341,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_USB_MINI_B = 'usb-mini-b'
|
||||
TYPE_USB_MICRO_A = 'usb-micro-a'
|
||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||
TYPE_USB_MICRO_AB = 'usb-micro-ab'
|
||||
TYPE_USB_3_B = 'usb-3-b'
|
||||
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
||||
# Direct current (DC)
|
||||
@@ -444,6 +449,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_MINI_B, 'USB Mini B'),
|
||||
(TYPE_USB_MICRO_A, 'USB Micro A'),
|
||||
(TYPE_USB_MICRO_B, 'USB Micro B'),
|
||||
(TYPE_USB_MICRO_AB, 'USB Micro AB'),
|
||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||
)),
|
||||
@@ -681,6 +687,18 @@ class PowerOutletFeedLegChoices(ChoiceSet):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceKindChoices(ChoiceSet):
|
||||
KIND_PHYSICAL = 'physical'
|
||||
KIND_VIRTUAL = 'virtual'
|
||||
KIND_WIRELESS = 'wireless'
|
||||
|
||||
CHOICES = (
|
||||
(KIND_PHYSICAL, 'Physical'),
|
||||
(KIND_VIRTUAL, 'Virtual'),
|
||||
(KIND_WIRELESS, 'Wireless'),
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTypeChoices(ChoiceSet):
|
||||
|
||||
# Virtual
|
||||
|
||||
@@ -10,14 +10,14 @@ from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.filters import (
|
||||
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
|
||||
TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
from .models import *
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CableFilterSet',
|
||||
'CableTerminationFilterSet',
|
||||
@@ -480,12 +480,21 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
|
||||
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
devicetype_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
field_name='device_type_id',
|
||||
label='Device type (ID)',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
@@ -1184,6 +1193,10 @@ class CableFilterSet(PrimaryModelFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
termination_a_type = ContentTypeFilter()
|
||||
termination_a_id = MultiValueNumberFilter()
|
||||
termination_b_type = ContentTypeFilter()
|
||||
termination_b_id = MultiValueNumberFilter()
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@@ -1228,7 +1241,7 @@ class CableFilterSet(PrimaryModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = ['id', 'label', 'length', 'length_unit']
|
||||
fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1243,73 +1256,6 @@ class CableFilterSet(PrimaryModelFilterSet):
|
||||
return queryset
|
||||
|
||||
|
||||
class ConnectionFilterSet(BaseFilterSet):
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(**{f'{name}__in': value})
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerConnectionFilterSet(ConnectionFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceConnectionFilterSet(ConnectionFilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
|
||||
|
||||
class PowerPanelFilterSet(PrimaryModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
@@ -1441,3 +1387,52 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
#
|
||||
# Connection filter sets
|
||||
#
|
||||
|
||||
class ConnectionFilterSet(BaseFilterSet):
|
||||
site_id = MultiValueNumberFilter(
|
||||
method='filter_connections',
|
||||
field_name='device__site_id'
|
||||
)
|
||||
site = MultiValueCharFilter(
|
||||
method='filter_connections',
|
||||
field_name='device__site__slug'
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_connections',
|
||||
field_name='device_id'
|
||||
)
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_connections',
|
||||
field_name='device__name'
|
||||
)
|
||||
|
||||
def filter_connections(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(**{f'{name}__in': value})
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerConnectionFilterSet(ConnectionFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceConnectionFilterSet(ConnectionFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = []
|
||||
|
||||
@@ -957,9 +957,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address'],
|
||||
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
]
|
||||
kind = forms.MultipleChoiceField(
|
||||
choices=InterfaceKindChoices,
|
||||
required=False,
|
||||
widget=StaticSelectMultiple()
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
required=False,
|
||||
|
||||
@@ -38,6 +38,7 @@ __all__ = (
|
||||
'LocationForm',
|
||||
'ManufacturerForm',
|
||||
'PlatformForm',
|
||||
'PopulateDeviceBayForm',
|
||||
'PowerFeedForm',
|
||||
'PowerOutletForm',
|
||||
'PowerOutletTemplateForm',
|
||||
@@ -52,6 +53,7 @@ __all__ = (
|
||||
'RegionForm',
|
||||
'SiteForm',
|
||||
'SiteGroupForm',
|
||||
'VCMemberSelectForm',
|
||||
'VirtualChassisForm',
|
||||
)
|
||||
|
||||
|
||||
@@ -117,12 +117,18 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
|
||||
raise forms.ValidationError({
|
||||
'initial_position': "A position must be specified for the first VC member."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
# Assign VC members
|
||||
if instance.pk:
|
||||
initial_position = self.cleaned_data.get('initial_position') or 1
|
||||
if instance.pk and self.cleaned_data['members']:
|
||||
initial_position = self.cleaned_data.get('initial_position', 1)
|
||||
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
|
||||
member.virtual_chassis = instance
|
||||
member.vc_position = i
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ class RackElevationSVG:
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
link.add(drawing.text(str(name), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||
rect = drawing.rect(start, end, class_="slot blocked")
|
||||
@@ -129,17 +132,24 @@ class RackElevationSVG:
|
||||
)
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
drawing.add(drawing.text(str(device), insert=text, stroke='black',
|
||||
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
||||
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}?{}'.format(
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': rack.site.pk,
|
||||
'location': rack.location.pk if rack.location else '',
|
||||
'rack': rack.pk,
|
||||
'face': face_id,
|
||||
'position': id_
|
||||
})
|
||||
)
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}?{}'.format(
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
|
||||
),
|
||||
target='_top'
|
||||
)
|
||||
drawing.a(href=link_url, target='_top')
|
||||
)
|
||||
if reservation:
|
||||
link.set_desc('{} — {} · {}'.format(
|
||||
|
||||
@@ -43,6 +43,7 @@ class ConsoleConnectionTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class PowerConnectionTable(BaseTable):
|
||||
@@ -73,6 +74,7 @@ class PowerConnectionTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class InterfaceConnectionTable(BaseTable):
|
||||
@@ -106,3 +108,4 @@ class InterfaceConnectionTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
|
||||
exclude = ('id', )
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Cable
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
|
||||
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
|
||||
|
||||
__all__ = (
|
||||
@@ -16,10 +16,6 @@ __all__ = (
|
||||
|
||||
class CableTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
id = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='ID'
|
||||
)
|
||||
termination_a_parent = tables.TemplateColumn(
|
||||
template_code=CABLE_TERMINATION_PARENT,
|
||||
accessor=Accessor('termination_a'),
|
||||
@@ -45,7 +41,7 @@ class CableTable(BaseTable):
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
length = tables.TemplateColumn(
|
||||
length = TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by='_abs_length'
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from dcim.models import (
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
MarkdownColumn, TagColumn, ToggleColumn,
|
||||
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import (
|
||||
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
|
||||
@@ -53,6 +53,14 @@ def get_cabletermination_row_class(record):
|
||||
return ''
|
||||
|
||||
|
||||
def get_interface_row_class(record):
|
||||
if not record.enabled:
|
||||
return 'danger'
|
||||
elif record.is_virtual:
|
||||
return 'primary'
|
||||
return get_cabletermination_row_class(record)
|
||||
|
||||
|
||||
def get_interface_state_attribute(record):
|
||||
"""
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
@@ -88,7 +96,7 @@ class DeviceRoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -116,7 +124,7 @@ class PlatformTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
fields = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
|
||||
'description', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -196,7 +204,7 @@ class DeviceTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = (
|
||||
'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
|
||||
)
|
||||
@@ -227,7 +235,7 @@ class DeviceImportTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Device
|
||||
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
empty_text = False
|
||||
|
||||
|
||||
@@ -258,7 +266,7 @@ class CableTerminationTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
cable_peer = tables.TemplateColumn(
|
||||
cable_peer = TemplateColumn(
|
||||
accessor='_cable_peer',
|
||||
template_code=CABLETERMINATION,
|
||||
orderable=False,
|
||||
@@ -268,7 +276,7 @@ class CableTerminationTable(BaseTable):
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = tables.TemplateColumn(
|
||||
connection = TemplateColumn(
|
||||
accessor='_path.last_node',
|
||||
template_code=CABLETERMINATION,
|
||||
verbose_name='Connection',
|
||||
@@ -290,7 +298,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
@@ -311,7 +319,7 @@ class DeviceConsolePortTable(ConsolePortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags', 'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
|
||||
@@ -334,7 +342,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
@@ -356,7 +364,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
|
||||
@@ -379,7 +387,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
|
||||
'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
@@ -401,7 +409,7 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
|
||||
'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -430,7 +438,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
@@ -451,7 +459,7 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
|
||||
'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -470,7 +478,7 @@ class BaseInterfaceTable(BaseTable):
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
untagged_vlan = tables.Column(linkify=True)
|
||||
tagged_vlans = tables.TemplateColumn(
|
||||
tagged_vlans = TemplateColumn(
|
||||
template_code=INTERFACE_TAGGED_VLANS,
|
||||
orderable=False,
|
||||
verbose_name='Tagged VLANs'
|
||||
@@ -492,7 +500,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
|
||||
'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
@@ -501,8 +509,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
|
||||
class DeviceInterfaceTable(InterfaceTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
|
||||
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
|
||||
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}reorder-horizontal'
|
||||
'{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
|
||||
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
@@ -524,7 +532,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
|
||||
'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
@@ -534,7 +542,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'cable', 'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class,
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
}
|
||||
@@ -561,7 +569,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -585,7 +593,7 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
|
||||
'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -612,7 +620,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
|
||||
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
||||
@@ -634,7 +642,7 @@ class DeviceRearPortTable(RearPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
|
||||
'cable_peer', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -653,7 +661,8 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
}
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=DEVICEBAY_STATUS
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
order_by=Accessor('installed_device__status')
|
||||
)
|
||||
installed_device = tables.Column(
|
||||
linkify=True
|
||||
@@ -664,7 +673,7 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
@@ -684,7 +693,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
|
||||
@@ -710,7 +719,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
|
||||
'discovered', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
|
||||
@@ -731,7 +740,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
|
||||
'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -763,5 +772,5 @@ class VirtualChassisTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
|
||||
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
|
||||
|
||||
@@ -46,6 +46,9 @@ class ManufacturerTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
)
|
||||
|
||||
@@ -76,7 +79,7 @@ class DeviceTypeTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'comments', 'instance_count', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -90,10 +93,16 @@ class DeviceTypeTable(BaseTable):
|
||||
|
||||
class ComponentTemplateTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
id = tables.Column(
|
||||
verbose_name='ID'
|
||||
)
|
||||
name = tables.Column(
|
||||
order_by=('_name',)
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
@@ -102,7 +111,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_consoleports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = ConsolePortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -115,7 +124,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_consoleserverports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -128,7 +137,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_powerports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = PowerPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -141,7 +150,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_poweroutlets'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = PowerOutletTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -157,7 +166,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_interfaces'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = InterfaceTemplate
|
||||
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -174,7 +183,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_frontports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = FrontPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -188,7 +197,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_rearports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = RearPortTemplate
|
||||
fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
@@ -201,7 +210,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
return_url_extra='%23tab_devicebays'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
model = DeviceBayTemplate
|
||||
fields = ('pk', 'name', 'label', 'description', 'actions')
|
||||
empty_text = "None"
|
||||
|
||||
@@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
|
||||
'comments', 'tags',
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ class RackRoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackRole
|
||||
fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -72,12 +72,20 @@ class RackTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
outer_width = tables.TemplateColumn(
|
||||
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
|
||||
verbose_name='Outer Width'
|
||||
)
|
||||
outer_depth = tables.TemplateColumn(
|
||||
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
|
||||
verbose_name='Outer Depth'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
@@ -115,7 +123,7 @@ class RackReservationTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -33,7 +33,7 @@ class RegionTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Region
|
||||
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class SiteGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class SiteTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email', 'comments', 'tags',
|
||||
)
|
||||
@@ -120,5 +120,5 @@ class LocationTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Location
|
||||
fields = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'site', 'rack_count', 'device_count', 'description', 'slug', 'actions')
|
||||
default_columns = ('pk', 'name', 'site', 'rack_count', 'device_count', 'description', 'actions')
|
||||
|
||||
@@ -5,13 +5,11 @@ CABLETERMINATION = """
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
@@ -42,17 +40,13 @@ DEVICEBAY_STATUS = """
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
<div class="table-badge-group">
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
<a
|
||||
class="table-badge{% if ip.status != 'active' %} badge bg-{{ ip.get_status_class }}{% elif ip.role %} badge bg-{{ ip.get_role_class }}{% endif %}"
|
||||
href="{{ ip.get_absolute_url }}"
|
||||
{% if ip.status != 'active'%}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}"
|
||||
{% elif ip.role %}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_role_display }}"
|
||||
{% endif %}
|
||||
>
|
||||
{{ ip }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_class }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -63,8 +57,6 @@ INTERFACE_TAGGED_VLANS = """
|
||||
{% endfor %}
|
||||
{% elif record.mode == 'tagged-all' %}
|
||||
All
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@@ -1490,40 +1491,35 @@ class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
self.devicetype1 = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
self.devicetype2 = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
|
||||
)
|
||||
self.devicerole1 = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.devicerole2 = DeviceRole.objects.create(
|
||||
name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
|
||||
)
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
|
||||
self.device1 = Device.objects.create(
|
||||
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
|
||||
)
|
||||
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
|
||||
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
|
||||
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
|
||||
|
||||
cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
|
||||
cable.save()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_connected_device(self):
|
||||
|
||||
url = reverse('dcim-api:connected-device-list')
|
||||
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], self.device1.name)
|
||||
self.assertEqual(response.data['name'], self.device2.name)
|
||||
|
||||
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
|
||||
response = self.client.get(url + url_params, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@@ -2851,6 +2851,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
console_port = ConsolePort.objects.create(device=devices[0], name='Console Port 1')
|
||||
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
|
||||
|
||||
# Cables
|
||||
Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
|
||||
Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
|
||||
@@ -2858,6 +2861,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
|
||||
Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
|
||||
Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
|
||||
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['Cable 1', 'Cable 2']}
|
||||
@@ -2877,7 +2881,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'status': [CableStatusChoices.STATUS_PLANNED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
@@ -2888,30 +2892,44 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_rack(self):
|
||||
racks = Rack.objects.all()[:2]
|
||||
params = {'rack_id': [racks[0].pk, racks[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'rack': [racks[0].name, racks[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_site(self):
|
||||
site = Site.objects.all()[:2]
|
||||
params = {'site_id': [site[0].pk, site[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'site': [site[0].slug, site[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_tenant(self):
|
||||
tenant = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_termination_types(self):
|
||||
params = {'termination_a_type': 'dcim.consoleport'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'termination_b_type': 'dcim.consoleserverport'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_termination_ids(self):
|
||||
interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3]
|
||||
params = {
|
||||
'termination_a_type': 'dcim.interface',
|
||||
'termination_a_id': list(interface_ids),
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from ipam.views import ServiceEditView
|
||||
from utilities.views import SlugRedirectView
|
||||
from . import views
|
||||
@@ -43,7 +43,6 @@ urlpatterns = [
|
||||
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
|
||||
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
|
||||
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
|
||||
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
|
||||
|
||||
# Locations
|
||||
path('locations/', views.LocationListView.as_view(), name='location_list'),
|
||||
@@ -55,7 +54,6 @@ urlpatterns = [
|
||||
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
|
||||
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
|
||||
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
|
||||
path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
|
||||
|
||||
# Rack roles
|
||||
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||
@@ -92,7 +90,6 @@ urlpatterns = [
|
||||
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
|
||||
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
|
||||
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
|
||||
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
|
||||
|
||||
# Manufacturers
|
||||
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||
@@ -229,7 +226,6 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
|
||||
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
# Console ports
|
||||
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
|
||||
|
||||
@@ -1229,6 +1229,7 @@ class PlatformView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'devices_table': devices_table,
|
||||
'virtualmachine_count': VirtualMachine.objects.filter(platform=instance).count()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
@@ -30,6 +31,7 @@ __all__ = (
|
||||
'ExportTemplateSerializer',
|
||||
'ImageAttachmentSerializer',
|
||||
'JobResultSerializer',
|
||||
'JournalEntrySerializer',
|
||||
'ObjectChangeSerializer',
|
||||
'ReportDetailSerializer',
|
||||
'ReportSerializer',
|
||||
@@ -192,6 +194,12 @@ class JournalEntrySerializer(ValidatedModelSerializer):
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||
created_by = serializers.PrimaryKeyRelatedField(
|
||||
allow_null=True,
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
default=serializers.CurrentUserDefault()
|
||||
)
|
||||
kind = ChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
required=False
|
||||
|
||||
@@ -15,6 +15,7 @@ from .models import *
|
||||
__all__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
@@ -47,7 +48,7 @@ class WebhookFilterSet(BaseFilterSet):
|
||||
]
|
||||
|
||||
|
||||
class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
class CustomFieldFilterSet(BaseFilterSet):
|
||||
content_types = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_links')
|
||||
limit_choices_to=FeatureQuery('export_templates')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -18,48 +18,60 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
|
||||
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
|
||||
self.stdout.write("[*] Clearing expired authentication sessions")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Clearing expired authentication sessions")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
try:
|
||||
engine.SessionStore.clear_expired()
|
||||
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
|
||||
if options['verbosity']:
|
||||
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
|
||||
except NotImplementedError:
|
||||
self.stdout.write(
|
||||
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
if options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
# Delete expired ObjectRecords
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if settings.CHANGELOG_RETENTION:
|
||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tCut-off time: {cutoff}")
|
||||
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
|
||||
if expired_records:
|
||||
self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="")
|
||||
self.stdout.flush()
|
||||
if options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tDeleting {expired_records} expired records... ",
|
||||
self.style.WARNING,
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
self.stdout.write("Done.", self.style.WARNING)
|
||||
else:
|
||||
self.stdout.write("\tNo expired records found.")
|
||||
else:
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
|
||||
)
|
||||
|
||||
# Check for new releases (if enabled)
|
||||
self.stdout.write("[*] Checking for latest release")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for latest release")
|
||||
if settings.RELEASE_CHECK_URL:
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
try:
|
||||
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
|
||||
response = requests.get(
|
||||
url=settings.RELEASE_CHECK_URL,
|
||||
headers=headers,
|
||||
@@ -73,15 +85,19 @@ class Command(BaseCommand):
|
||||
continue
|
||||
releases.append((version.parse(release['tag_name']), release.get('html_url')))
|
||||
latest_release = max(releases)
|
||||
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
|
||||
self.stdout.write(f"\tLatest release: {latest_release[0]}")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
|
||||
if options['verbosity']:
|
||||
self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS)
|
||||
|
||||
# Cache the most recent release
|
||||
cache.set('latest_release', latest_release, None)
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
self.stdout.write(f"\tRequest error: {exc}")
|
||||
self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
|
||||
else:
|
||||
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
|
||||
if options['verbosity']:
|
||||
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
|
||||
|
||||
self.stdout.write("Finished.", self.style.SUCCESS)
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Finished.", self.style.SUCCESS)
|
||||
|
||||
158
netbox/extras/management/commands/runscript.py
Normal file
158
netbox/extras/management/commands/runscript.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from extras.context_managers import change_logging
|
||||
from extras.models import JobResult
|
||||
from extras.scripts import get_script
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a script in Netbox"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--loglevel',
|
||||
help="Logging Level (default: info)",
|
||||
dest='loglevel',
|
||||
default='info',
|
||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
parser.add_argument('--commit', help="Commit this script to database", action='store_true')
|
||||
parser.add_argument('--user', help="User script is running as")
|
||||
parser.add_argument('--data', help="Data as a string encapsulated JSON blob")
|
||||
parser.add_argument('script', help="Script to run")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
def _run_script():
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data=data, commit=commit)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
|
||||
)
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
# Params
|
||||
script = options['script']
|
||||
loglevel = options['loglevel']
|
||||
commit = options['commit']
|
||||
try:
|
||||
data = json.loads(options['data'])
|
||||
except TypeError:
|
||||
data = {}
|
||||
|
||||
module, name = script.split('.', 1)
|
||||
|
||||
# Take user from command line if provided and exists, other
|
||||
if options['user']:
|
||||
try:
|
||||
user = User.objects.get(username=options['user'])
|
||||
except User.DoesNotExist:
|
||||
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
|
||||
else:
|
||||
user = User.objects.filter(is_superuser=True).order_by('pk')[0]
|
||||
|
||||
# Setup logging to Stdout
|
||||
formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s')
|
||||
stdouthandler = logging.StreamHandler(sys.stdout)
|
||||
stdouthandler.setLevel(logging.DEBUG)
|
||||
stdouthandler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger(f"netbox.scripts.{module}.{name}")
|
||||
logger.addHandler(stdouthandler)
|
||||
|
||||
try:
|
||||
logger.setLevel({
|
||||
'critical': logging.CRITICAL,
|
||||
'debug': logging.DEBUG,
|
||||
'error': logging.ERROR,
|
||||
'fatal': logging.FATAL,
|
||||
'info': logging.INFO,
|
||||
'warning': logging.WARNING,
|
||||
}[loglevel])
|
||||
except KeyError:
|
||||
raise CommandError(f"Invalid log level: {loglevel}")
|
||||
|
||||
# Get the script
|
||||
script = get_script(module, name)()
|
||||
# Parse the parameters
|
||||
form = script.as_form(data, None)
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
|
||||
# Delete any previous terminal state results
|
||||
JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
name=script.full_name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).delete()
|
||||
|
||||
# Create the job result
|
||||
job_result = JobResult.objects.create(
|
||||
name=script.full_name,
|
||||
obj_type=script_content_type,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
request = NetBoxFakeRequest({
|
||||
'META': {},
|
||||
'POST': data,
|
||||
'GET': {},
|
||||
'FILES': {},
|
||||
'user': user,
|
||||
'path': '',
|
||||
'id': job_result.job_id
|
||||
})
|
||||
|
||||
if form.is_valid():
|
||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
script.request = request
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
|
||||
# change logging, webhooks, etc.
|
||||
with change_logging(request):
|
||||
_run_script()
|
||||
else:
|
||||
logger.error('Data is not valid:')
|
||||
for field, errors in form.errors.get_json_data().items():
|
||||
for error in errors:
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
job_result.status = JobResultStatusChoices.STATUS_ERRORED
|
||||
job_result.save()
|
||||
@@ -7,6 +7,7 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.choices import *
|
||||
@@ -30,7 +31,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomField(ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
@@ -287,7 +288,7 @@ class CustomField(ChangeLoggedModel):
|
||||
field.model = self
|
||||
field.label = str(self)
|
||||
if self.description:
|
||||
field.help_text = self.description
|
||||
field.help_text = escape(self.description)
|
||||
|
||||
return field
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format, time_format
|
||||
from django.utils.formats import date_format
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from extras.choices import *
|
||||
@@ -36,7 +36,7 @@ __all__ = (
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Webhook(ChangeLoggedModel):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
@@ -175,7 +175,7 @@ class Webhook(ChangeLoggedModel):
|
||||
# Custom links
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomLink(ChangeLoggedModel):
|
||||
"""
|
||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||
@@ -234,7 +234,7 @@ class CustomLink(ChangeLoggedModel):
|
||||
# Export templates
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class ExportTemplate(ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
@@ -357,6 +357,8 @@ class ImageAttachment(BigIDModel):
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = ('content_type', 'object_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # name may be non-unique
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from utilities.querysets import RestrictedQuerySet
|
||||
# Tags
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Tag(ChangeLoggedModel, TagBase):
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import traceback
|
||||
import warnings
|
||||
from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
@@ -345,9 +344,14 @@ class BaseScript:
|
||||
"""
|
||||
Return data from a YAML file
|
||||
"""
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
with open(file_path, 'r') as datafile:
|
||||
data = yaml.load(datafile)
|
||||
data = yaml.load(datafile, Loader=Loader)
|
||||
|
||||
return data
|
||||
|
||||
@@ -470,7 +474,6 @@ def get_scripts(use_names=False):
|
||||
defined name in place of the actual module name.
|
||||
"""
|
||||
scripts = OrderedDict()
|
||||
|
||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
@@ -478,8 +481,11 @@ def get_scripts(use_names=False):
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
for name, cls in inspect.getmembers(module, is_script):
|
||||
module_scripts[name] = cls
|
||||
script_order = getattr(module, "script_order", ())
|
||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
||||
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
|
||||
for cls in [*ordered_scripts, *unordered_scripts]:
|
||||
module_scripts[cls.__name__] = cls
|
||||
if module_scripts:
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ class CustomFieldTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', 'description',
|
||||
'filter_logic', 'choices',
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'choices',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
|
||||
|
||||
@@ -78,7 +78,8 @@ class CustomLinkTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CustomLink
|
||||
fields = (
|
||||
'pk', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
|
||||
|
||||
@@ -98,7 +99,7 @@ class ExportTemplateTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ExportTemplate
|
||||
fields = (
|
||||
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
@@ -132,7 +133,7 @@ class WebhookTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -155,10 +156,16 @@ class TagTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Tag
|
||||
fields = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
|
||||
|
||||
class TaggedItemTable(BaseTable):
|
||||
id = tables.Column(
|
||||
verbose_name='ID',
|
||||
linkify=lambda record: record.content_object.get_absolute_url(),
|
||||
accessor='content_object__id'
|
||||
)
|
||||
content_type = ContentTypeColumn(
|
||||
verbose_name='Type'
|
||||
)
|
||||
@@ -170,7 +177,7 @@ class TaggedItemTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = TaggedItem
|
||||
fields = ('content_type', 'content_object')
|
||||
fields = ('id', 'content_type', 'content_object')
|
||||
|
||||
|
||||
class ConfigContextTable(BaseTable):
|
||||
@@ -185,8 +192,8 @@ class ConfigContextTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
|
||||
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
|
||||
@@ -211,7 +218,7 @@ class ObjectChangeTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ObjectChange
|
||||
fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||
|
||||
|
||||
class ObjectJournalTable(BaseTable):
|
||||
@@ -232,7 +239,7 @@ class ObjectJournalTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = JournalEntry
|
||||
fields = ('created', 'created_by', 'kind', 'comments', 'actions')
|
||||
fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions')
|
||||
|
||||
|
||||
class JournalEntryTable(ObjectJournalTable):
|
||||
@@ -250,5 +257,10 @@ class JournalEntryTable(ObjectJournalTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = JournalEntry
|
||||
fields = (
|
||||
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions'
|
||||
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
|
||||
'comments', 'actions'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
|
||||
'comments', 'actions'
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import tempfile
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from netaddr import IPAddress, IPNetwork
|
||||
@@ -11,6 +13,50 @@ CHOICES = (
|
||||
('0000ff', 'Blue')
|
||||
)
|
||||
|
||||
YAML_DATA = """
|
||||
Foo: 123
|
||||
Bar: 456
|
||||
Baz:
|
||||
- A
|
||||
- B
|
||||
- C
|
||||
"""
|
||||
|
||||
JSON_DATA = """
|
||||
{
|
||||
"Foo": 123,
|
||||
"Bar": 456,
|
||||
"Baz": ["A", "B", "C"]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ScriptTest(TestCase):
|
||||
|
||||
def test_load_yaml(self):
|
||||
datafile = tempfile.NamedTemporaryFile()
|
||||
datafile.write(bytes(YAML_DATA, 'UTF-8'))
|
||||
datafile.seek(0)
|
||||
|
||||
data = Script().load_yaml(datafile.name)
|
||||
self.assertEqual(data, {
|
||||
'Foo': 123,
|
||||
'Bar': 456,
|
||||
'Baz': ['A', 'B', 'C'],
|
||||
})
|
||||
|
||||
def test_load_json(self):
|
||||
datafile = tempfile.NamedTemporaryFile()
|
||||
datafile.write(bytes(JSON_DATA, 'UTF-8'))
|
||||
datafile.seek(0)
|
||||
|
||||
data = Script().load_json(datafile.name)
|
||||
self.assertEqual(data, {
|
||||
'Foo': 123,
|
||||
'Bar': 456,
|
||||
'Baz': ['A', 'B', 'C'],
|
||||
})
|
||||
|
||||
|
||||
class ScriptVariablesTest(TestCase):
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ urlpatterns = [
|
||||
kwargs={'model': models.ConfigContext}),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
|
||||
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
|
||||
@@ -472,22 +472,22 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
model_form = forms.ImageAttachmentForm
|
||||
|
||||
def alter_obj(self, imageattachment, request, args, kwargs):
|
||||
if not imageattachment.pk:
|
||||
def alter_obj(self, instance, request, args, kwargs):
|
||||
if not instance.pk:
|
||||
# Assign the parent object based on URL kwargs
|
||||
model = kwargs.get('model')
|
||||
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
|
||||
return imageattachment
|
||||
content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
|
||||
instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
|
||||
return instance
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
def get_return_url(self, request, obj=None):
|
||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
||||
|
||||
|
||||
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
def get_return_url(self, request, obj=None):
|
||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -257,11 +257,18 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
device = self.cleaned_data.get('device')
|
||||
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||
interface = self.cleaned_data.get('interface')
|
||||
is_primary = self.cleaned_data.get('is_primary')
|
||||
|
||||
# Validate is_primary
|
||||
if is_primary and not device and not virtual_machine:
|
||||
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
|
||||
raise forms.ValidationError({
|
||||
"is_primary": "No device or virtual machine specified; cannot set as primary IP"
|
||||
})
|
||||
if is_primary and not interface:
|
||||
raise forms.ValidationError({
|
||||
"is_primary": "No interface specified; cannot set as primary IP"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import django_filters
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -409,7 +410,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['group_id', 'status', 'role_id'],
|
||||
['group_id', 'status', 'role_id', 'vid'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
@@ -461,6 +462,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
vid = forms.IntegerField(
|
||||
required=False,
|
||||
label='VLAN ID'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{{ record.prefix }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -97,7 +89,7 @@ class RIRTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -129,7 +121,7 @@ class AggregateTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
|
||||
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
|
||||
|
||||
|
||||
@@ -156,7 +148,7 @@ class RoleTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions')
|
||||
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -218,8 +210,8 @@ class PrefixTable(BaseTable):
|
||||
linkify=True,
|
||||
verbose_name='VLAN'
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=PREFIX_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
is_pool = BooleanColumn(
|
||||
verbose_name='Pool'
|
||||
@@ -238,7 +230,7 @@ class PrefixTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
|
||||
'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -264,15 +256,23 @@ class IPRangeTable(BaseTable):
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=PREFIX_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
utilization = UtilizationColumn(
|
||||
accessor='utilization',
|
||||
orderable=False
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:iprange_list'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPRange
|
||||
fields = (
|
||||
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
'utilization', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
@@ -329,7 +329,7 @@ class IPAddressTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'tags',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -353,6 +353,7 @@ class IPAddressAssignTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
|
||||
exclude = ('id', )
|
||||
orderable = False
|
||||
|
||||
|
||||
@@ -377,3 +378,4 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
|
||||
exclude = ('id', )
|
||||
|
||||
@@ -31,5 +31,5 @@ class ServiceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Service
|
||||
fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.models import Interface
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn,
|
||||
TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
from ipam.models import *
|
||||
@@ -35,19 +35,9 @@ VLAN_LINK = """
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% if next_vid and perms.ipam.add_vlan %}
|
||||
@@ -91,7 +81,7 @@ class VLANGroupTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
|
||||
|
||||
|
||||
@@ -103,7 +93,7 @@ class VLANTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
vid = tables.TemplateColumn(
|
||||
template_code=VLAN_LINK,
|
||||
verbose_name='ID'
|
||||
verbose_name='VID'
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
@@ -115,10 +105,10 @@ class VLANTable(BaseTable):
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
prefixes = tables.TemplateColumn(
|
||||
prefixes = TemplateColumn(
|
||||
template_code=VLAN_PREFIXES,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
@@ -129,7 +119,7 @@ class VLANTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
@@ -159,6 +149,7 @@ class VLANDevicesTable(VLANMembersTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
@@ -170,6 +161,7 @@ class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = ('virtual_machine', 'name', 'tagged', 'actions')
|
||||
exclude = ('id', )
|
||||
|
||||
|
||||
class InterfaceVLANTable(BaseTable):
|
||||
@@ -190,13 +182,14 @@ class InterfaceVLANTable(BaseTable):
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
|
||||
exclude = ('id', )
|
||||
|
||||
def __init__(self, interface, *args, **kwargs):
|
||||
self.interface = interface
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
@@ -11,9 +11,7 @@ __all__ = (
|
||||
|
||||
VRF_TARGETS = """
|
||||
{% for rt in value.all %}
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
@@ -34,11 +32,11 @@ class VRFTable(BaseTable):
|
||||
enforce_unique = BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
import_targets = tables.TemplateColumn(
|
||||
import_targets = TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
export_targets = tables.TemplateColumn(
|
||||
export_targets = TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
@@ -49,7 +47,7 @@ class VRFTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
@@ -70,5 +68,5 @@ class RouteTargetTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'name', 'tenant', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
|
||||
@@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': f'within={instance.prefix}',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return et.render_to_response(queryset)
|
||||
|
||||
@@ -69,7 +69,13 @@ SEARCH_TYPES = OrderedDict((
|
||||
}),
|
||||
('location', {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
|
||||
@@ -40,11 +40,6 @@ class ChangeLoggingMixin(models.Model):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
object_changes = GenericRelation(
|
||||
to='extras.ObjectChange',
|
||||
content_type_field='changed_object_type',
|
||||
object_id_field='changed_object_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import warnings
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -16,7 +17,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.0.4'
|
||||
VERSION = '3.0.10'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -25,7 +26,7 @@ HOSTNAME = platform.node()
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Validate Python version
|
||||
if platform.python_version_tuple() < ('3', '7'):
|
||||
if sys.version_info < (3, 7):
|
||||
raise RuntimeError(
|
||||
f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from .admin import admin_site
|
||||
|
||||
openapi_info = openapi.Info(
|
||||
title="NetBox API",
|
||||
default_version='v2',
|
||||
default_version='v3',
|
||||
description="API to access NetBox",
|
||||
terms_of_service="https://github.com/netbox-community/netbox",
|
||||
license=openapi.License(name="Apache v2 License"),
|
||||
@@ -59,9 +59,9 @@ _patterns = [
|
||||
path('api/users/', include('users.api.urls')),
|
||||
path('api/virtualization/', include('virtualization.api.urls')),
|
||||
path('api/status/', StatusView.as_view(), name='api-status'),
|
||||
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
|
||||
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
|
||||
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
|
||||
path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
|
||||
path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
|
||||
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
|
||||
|
||||
# GraphQL
|
||||
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
|
||||
|
||||
@@ -137,7 +137,7 @@ class HomeView(View):
|
||||
release_version, release_url = latest_release
|
||||
if release_version > version.parse(settings.VERSION):
|
||||
new_release = {
|
||||
'version': str(latest_release),
|
||||
'version': str(release_version),
|
||||
'url': release_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -283,13 +283,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
redirect_url = request.path
|
||||
return_url = request.GET.get('return_url')
|
||||
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
|
||||
redirect_url = f'{redirect_url}?return_url={return_url}'
|
||||
|
||||
# If the object has clone_fields, pre-populate a new instance of the form
|
||||
if hasattr(obj, 'clone_fields'):
|
||||
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}"
|
||||
redirect_url += f"?{prepare_cloned_fields(obj)}"
|
||||
|
||||
return redirect(redirect_url)
|
||||
|
||||
@@ -780,8 +777,21 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
else:
|
||||
pk_list = request.POST.getlist('pk')
|
||||
|
||||
# Include the PK list as initial data for the form
|
||||
initial_data = {'pk': pk_list}
|
||||
|
||||
# Check for other contextual data needed for the form. We avoid passing all of request.GET because the
|
||||
# filter values will conflict with the bulk edit form fields.
|
||||
# TODO: Find a better way to accomplish this
|
||||
if 'device' in request.GET:
|
||||
initial_data['device'] = request.GET.get('device')
|
||||
elif 'device_type' in request.GET:
|
||||
initial_data['device_type'] = request.GET.get('device_type')
|
||||
elif 'virtual_machine' in request.GET:
|
||||
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
|
||||
|
||||
if '_apply' in request.POST:
|
||||
form = self.form(model, request.POST)
|
||||
form = self.form(model, request.POST, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
if form.is_valid():
|
||||
@@ -870,16 +880,6 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
else:
|
||||
# Include the PK list as initial data for the form
|
||||
initial_data = {'pk': pk_list}
|
||||
|
||||
# Check for other contextual data needed for the form. We avoid passing all of request.GET because the
|
||||
# filter values will conflict with the bulk edit form fields.
|
||||
# TODO: Find a better way to accomplish this
|
||||
if 'device' in request.GET:
|
||||
initial_data['device'] = request.GET.get('device')
|
||||
elif 'device_type' in request.GET:
|
||||
initial_data['device_type'] = request.GET.get('device_type')
|
||||
|
||||
form = self.form(model, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
4
netbox/project-static/dist/lldp.js
vendored
4
netbox/project-static/dist/lldp.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/lldp.js.map
vendored
2
netbox/project-static/dist/lldp.js.map
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
|
||||
|
||||
if (table !== null) {
|
||||
for (const element of table.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="checkbox"][name="pk"]',
|
||||
'tr:not(.d-none) input[type="checkbox"][name="pk"]',
|
||||
)) {
|
||||
if (tableSelectAll.checked) {
|
||||
// Check all PK checkboxes if the select all checkbox is checked.
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { createToast } from '../bs';
|
||||
import { getNetboxData, apiGetBase, hasError, isTruthy, toggleLoader } from '../util';
|
||||
|
||||
// Match an interface name that begins with a capital letter and is followed by at least one other
|
||||
// alphabetic character, and ends with a forward-slash-separated numeric sequence such as 0/1/2.
|
||||
const CISCO_IOS_PATTERN = new RegExp(/^([A-Z][A-Za-z]+)[^0-9]*([0-9/]+)$/);
|
||||
|
||||
// Mapping of overrides to default Cisco IOS interface alias behavior (default behavior is to use
|
||||
// the first two characters).
|
||||
const CISCO_IOS_OVERRIDES = new Map<string, string>([
|
||||
// Cisco IOS abbreviates 25G (TwentyFiveGigE) interfaces as 'Twe'.
|
||||
['TwentyFiveGigE', 'Twe'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get an attribute from a row's cell.
|
||||
*
|
||||
@@ -12,6 +23,40 @@ function getData(row: HTMLTableRowElement, query: string, attr: string): string
|
||||
return row.querySelector(query)?.getAttribute(attr) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preconfigured alias for given interface. Primarily for matching long-form Cisco IOS
|
||||
* interface names with short-form Cisco IOS interface names. For example, `GigabitEthernet0/1/2`
|
||||
* would become `Gi0/1/2`.
|
||||
*
|
||||
* This should probably be replaced with something in the primary application (Django), such as
|
||||
* a database field attached to given interface types. However, this is a temporary measure to
|
||||
* replace the functionality of this one-liner:
|
||||
*
|
||||
* @see https://github.com/netbox-community/netbox/blob/9cc4992fad2fe04ef0211d998c517414e8871d8c/netbox/templates/dcim/device/lldp_neighbors.html#L69
|
||||
*
|
||||
* @param name Long-form/original interface name.
|
||||
*/
|
||||
function getInterfaceAlias(name: string | null): string | null {
|
||||
if (name === null) {
|
||||
return name;
|
||||
}
|
||||
if (name.match(CISCO_IOS_PATTERN)) {
|
||||
// Extract the base name and numeric portions of the interface. For example, an input interface
|
||||
// of `GigabitEthernet0/0/1` would result in an array of `['GigabitEthernet', '0/0/1']`.
|
||||
const [base, numeric] = (name.match(CISCO_IOS_PATTERN) ?? []).slice(1, 3);
|
||||
|
||||
if (isTruthy(base) && isTruthy(numeric)) {
|
||||
// Check the override map and use its value if the base name is present in the map.
|
||||
// Otherwise, use the first two characters of the base name. For example,
|
||||
// `GigabitEthernet0/0/1` would become `Gi0/0/1`, but `TwentyFiveGigE0/0/1` would become
|
||||
// `Twe0/0/1`.
|
||||
const aliasBase = CISCO_IOS_OVERRIDES.get(base) || base.slice(0, 2);
|
||||
return `${aliasBase}${numeric}`;
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update row styles based on LLDP neighbor data.
|
||||
*/
|
||||
@@ -23,38 +68,41 @@ function updateRowStyle(data: LLDPNeighborDetail) {
|
||||
|
||||
if (row !== null) {
|
||||
for (const neighbor of neighbors) {
|
||||
const cellDevice = row.querySelector<HTMLTableCellElement>('td.device');
|
||||
const cellInterface = row.querySelector<HTMLTableCellElement>('td.interface');
|
||||
const cDevice = getData(row, 'td.configured_device', 'data');
|
||||
const cChassis = getData(row, 'td.configured_chassis', 'data-chassis');
|
||||
const cInterface = getData(row, 'td.configured_interface', 'data');
|
||||
const deviceCell = row.querySelector<HTMLTableCellElement>('td.device');
|
||||
const interfaceCell = row.querySelector<HTMLTableCellElement>('td.interface');
|
||||
const configuredDevice = getData(row, 'td.configured_device', 'data');
|
||||
const configuredChassis = getData(row, 'td.configured_chassis', 'data-chassis');
|
||||
const configuredIface = getData(row, 'td.configured_interface', 'data');
|
||||
|
||||
let cInterfaceShort = null;
|
||||
if (isTruthy(cInterface)) {
|
||||
cInterfaceShort = cInterface.replace(/^([A-Z][a-z])[^0-9]*([0-9/]+)$/, '$1$2');
|
||||
const interfaceAlias = getInterfaceAlias(configuredIface);
|
||||
|
||||
const remoteName = neighbor.remote_system_name ?? '';
|
||||
const remotePort = neighbor.remote_port ?? '';
|
||||
const [neighborDevice] = remoteName.split('.');
|
||||
const [neighborIface] = remotePort.split('.');
|
||||
|
||||
if (deviceCell !== null) {
|
||||
deviceCell.innerText = neighborDevice;
|
||||
}
|
||||
|
||||
const nHost = neighbor.remote_system_name ?? '';
|
||||
const nPort = neighbor.remote_port ?? '';
|
||||
const [nDevice] = nHost.split('.');
|
||||
const [nInterface] = nPort.split('.');
|
||||
|
||||
if (cellDevice !== null) {
|
||||
cellDevice.innerText = nDevice;
|
||||
if (interfaceCell !== null) {
|
||||
interfaceCell.innerText = neighborIface;
|
||||
}
|
||||
|
||||
if (cellInterface !== null) {
|
||||
cellInterface.innerText = nInterface;
|
||||
}
|
||||
// Interface has an LLDP neighbor, but the neighbor is not configured in NetBox.
|
||||
const nonConfiguredDevice = !isTruthy(configuredDevice) && isTruthy(neighborDevice);
|
||||
|
||||
if (!isTruthy(cDevice) && isTruthy(nDevice)) {
|
||||
// NetBox device or chassis matches LLDP neighbor.
|
||||
const validNode =
|
||||
configuredDevice === neighborDevice || configuredChassis === neighborDevice;
|
||||
|
||||
// NetBox configured interface matches LLDP neighbor interface.
|
||||
const validInterface =
|
||||
configuredIface === neighborIface || interfaceAlias === neighborIface;
|
||||
|
||||
if (nonConfiguredDevice) {
|
||||
row.classList.add('info');
|
||||
} else if (
|
||||
(cDevice === nDevice || cChassis === nDevice) &&
|
||||
cInterfaceShort === nInterface
|
||||
) {
|
||||
row.classList.add('success');
|
||||
} else if (cDevice === nDevice || cChassis === nDevice) {
|
||||
} else if (validNode && validInterface) {
|
||||
row.classList.add('success');
|
||||
} else {
|
||||
row.classList.add('danger');
|
||||
|
||||
@@ -1,92 +1,90 @@
|
||||
import { rackImagesState } from './stores';
|
||||
import { rackImagesState, RackViewSelection } from './stores';
|
||||
import { getElements } from './util';
|
||||
|
||||
import type { StateManager } from './state';
|
||||
|
||||
type RackToggleState = { hidden: boolean };
|
||||
export type RackViewState = { view: RackViewSelection };
|
||||
|
||||
/**
|
||||
* Toggle the Rack Image button to reflect the current state. If the current state is hidden and
|
||||
* the images are therefore hidden, the button should say "Show Images". Likewise, if the current
|
||||
* state is *not* hidden, and therefore the images are shown, the button should say "Hide Images".
|
||||
*
|
||||
* @param hidden Current State - `true` if images are hidden, `false` otherwise.
|
||||
* @param button Button element.
|
||||
* Show or hide images and labels to build the desired rack view.
|
||||
*/
|
||||
function toggleRackImagesButton(hidden: boolean, button: HTMLButtonElement): void {
|
||||
const text = hidden ? 'Show Images' : 'Hide Images';
|
||||
const selected = hidden ? '' : 'selected';
|
||||
button.setAttribute('selected', selected);
|
||||
button.innerHTML = `<i class="mdi mdi-file-image-outline"></i> ${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all rack images.
|
||||
*/
|
||||
function showRackImages(): void {
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
|
||||
for (const image of images) {
|
||||
image.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide all rack images.
|
||||
*/
|
||||
function hideRackImages(): void {
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const images = elevation.contentDocument?.querySelectorAll('image.device-image') ?? [];
|
||||
for (const image of images) {
|
||||
image.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of device images and update the toggle button style.
|
||||
*/
|
||||
function handleRackImageToggle(
|
||||
target: HTMLButtonElement,
|
||||
state: StateManager<RackToggleState>,
|
||||
function setRackView(
|
||||
view: RackViewSelection,
|
||||
elevation: HTMLObjectElement,
|
||||
): void {
|
||||
const initiallyHidden = state.get('hidden');
|
||||
state.set('hidden', !initiallyHidden);
|
||||
const hidden = state.get('hidden');
|
||||
|
||||
if (hidden) {
|
||||
hideRackImages();
|
||||
} else {
|
||||
showRackImages();
|
||||
switch(view) {
|
||||
case 'images-and-labels': {
|
||||
showRackElements('image.device-image', elevation);
|
||||
showRackElements('text.device-image-label', elevation);
|
||||
break;
|
||||
}
|
||||
case 'images-only': {
|
||||
showRackElements('image.device-image', elevation);
|
||||
hideRackElements('text.device-image-label', elevation);
|
||||
break;
|
||||
}
|
||||
case 'labels-only': {
|
||||
hideRackElements('image.device-image', elevation);
|
||||
hideRackElements('text.device-image-label', elevation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showRackElements(
|
||||
selector: string,
|
||||
elevation: HTMLObjectElement,
|
||||
): void {
|
||||
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
|
||||
for (const element of elements) {
|
||||
element.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function hideRackElements(
|
||||
selector: string,
|
||||
elevation: HTMLObjectElement,
|
||||
): void {
|
||||
const elements = elevation.contentDocument?.querySelectorAll(selector) ?? [];
|
||||
for (const element of elements) {
|
||||
element.classList.add('hidden');
|
||||
}
|
||||
toggleRackImagesButton(hidden, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add onClick callback for toggling rack elevation images. Synchronize the image toggle button
|
||||
* text and display state of images with the local state.
|
||||
* Change the visibility of all racks in response to selection.
|
||||
*/
|
||||
function handleRackViewSelect(
|
||||
newView: RackViewSelection,
|
||||
state: StateManager<RackViewState>,
|
||||
): void {
|
||||
state.set('view', newView);
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
setRackView(newView, elevation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add change callback for selecting rack elevation images, and set
|
||||
* initial state of select and the images themselves
|
||||
*/
|
||||
export function initRackElevation(): void {
|
||||
const initiallyHidden = rackImagesState.get('hidden');
|
||||
for (const button of getElements<HTMLButtonElement>('button.toggle-images')) {
|
||||
toggleRackImagesButton(initiallyHidden, button);
|
||||
const initialView = rackImagesState.get('view');
|
||||
|
||||
button.addEventListener(
|
||||
'click',
|
||||
for (const control of getElements<HTMLSelectElement>('select.rack-view')) {
|
||||
control.selectedIndex = [...control.options].findIndex(o => o.value == initialView);
|
||||
control.addEventListener(
|
||||
'change',
|
||||
event => {
|
||||
handleRackImageToggle(event.currentTarget as HTMLButtonElement, rackImagesState);
|
||||
handleRackViewSelect((event.currentTarget as any).value as RackViewSelection, rackImagesState);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
element.addEventListener('load', () => {
|
||||
if (initiallyHidden) {
|
||||
hideRackImages();
|
||||
} else if (!initiallyHidden) {
|
||||
showRackImages();
|
||||
}
|
||||
setRackView(initialView, element);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +266,8 @@ class SideNav {
|
||||
for (const link of this.getActiveLinks()) {
|
||||
this.activateLink(link, 'collapse');
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.bodyRemove('hide');
|
||||
this.bodyAdd('hidden');
|
||||
}, 300);
|
||||
this.bodyRemove('hide');
|
||||
this.bodyAdd('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createState } from '../state';
|
||||
|
||||
export const rackImagesState = createState<{ hidden: boolean }>(
|
||||
{ hidden: false },
|
||||
export type RackViewSelection = 'images-and-labels' | 'images-only' | 'labels-only';
|
||||
|
||||
export const rackImagesState = createState<{ view: RackViewSelection }>(
|
||||
{ view: 'images-and-labels' },
|
||||
{ persist: true },
|
||||
);
|
||||
|
||||
@@ -73,16 +73,6 @@
|
||||
color: color-contrast($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Use proper foreground color in the alert body. Note: this is applied to p, & small because
|
||||
// we *don't* want to override the h1-h6 colors for alerts, since those are set to a color
|
||||
// similar to the alert color.
|
||||
.alert.alert-#{$color} {
|
||||
p,
|
||||
small {
|
||||
color: color-contrast($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure progress bars (utilization graph) in tables aren't too narrow to display the percentage.
|
||||
@@ -200,16 +190,27 @@ div#advanced-search-content div.card div.card-body div.col:not(:last-child) {
|
||||
}
|
||||
|
||||
table {
|
||||
a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
td {
|
||||
a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.dropdown {
|
||||
// Presence of 'overflow: scroll' on a table causes dropdowns to be improperly hidden when
|
||||
// opened. See: https://github.com/twbs/bootstrap/issues/24251
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
&.table > :not(caption) > * > * {
|
||||
padding-right: $table-cell-padding-x-sm !important;
|
||||
padding-left: $table-cell-padding-x-sm !important;
|
||||
th {
|
||||
a,
|
||||
a:hover {
|
||||
color: $body-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: $font-size-sm;
|
||||
@@ -234,6 +235,11 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
&.table > :not(caption) > * > * {
|
||||
padding-right: $table-cell-padding-x-sm !important;
|
||||
padding-left: $table-cell-padding-x-sm !important;
|
||||
}
|
||||
|
||||
&.object-list {
|
||||
th {
|
||||
font-size: $font-size-xs;
|
||||
@@ -808,7 +814,7 @@ table .table-badge-group {
|
||||
}
|
||||
|
||||
&.badge:not(:last-of-type):not(:only-child) {
|
||||
margin-bottom: map.get($spacers, 2);
|
||||
margin-bottom: map.get($spacers, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ $spacing-s: $input-padding-x;
|
||||
span.arrow-down,
|
||||
span.arrow-up {
|
||||
border-color: currentColor;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
// Don't show the depth indicator outside of the menu.
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
// Navbar brand
|
||||
.sidenav-brand {
|
||||
margin-right: 0;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.sidenav-brand-icon {
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.sidenav-inner {
|
||||
@@ -141,7 +146,17 @@
|
||||
}
|
||||
|
||||
.sidenav-toggle {
|
||||
display: none;
|
||||
// The sidenav toggle's default state is "hidden". Because modifying the `display` property
|
||||
// isn't ideal for smooth transitions, combine opacity 0 (transparent) and position absolute
|
||||
// to yield a similar result.
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
// The transition itself is largely irrelevant, but CSS needs *something* to transition in
|
||||
// order to apply a delay.
|
||||
transition: opacity 10ms ease-in-out;
|
||||
// Offset the transition delay so the icon isn't visible during the logo transition.
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
|
||||
.sidenav-collapse {
|
||||
@@ -350,13 +365,21 @@
|
||||
.sidenav-brand {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: translateX(-150%);
|
||||
}
|
||||
|
||||
.sidenav-brand-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidenav-toggle {
|
||||
// Immediately hide the toggle when the sidenav is closed, so it doesn't linger and overlap
|
||||
// with the logo elements.
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transition: unset;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
|
||||
.navbar-nav > .nav-item {
|
||||
> .nav-link {
|
||||
&:after {
|
||||
@@ -402,7 +425,8 @@
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.sidenav-toggle {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ $btn-link-disabled-color: $gray-300;
|
||||
|
||||
// Forms
|
||||
$component-active-bg: $primary;
|
||||
$component-active-color: $black;
|
||||
$form-text-color: $text-muted;
|
||||
$input-bg: $gray-900;
|
||||
$input-disabled-bg: $gray-700;
|
||||
|
||||
@@ -7,6 +7,7 @@ $input-border-color: $gray-200;
|
||||
$theme-colors: map-merge(
|
||||
$theme-colors,
|
||||
(
|
||||
'primary': #337ab7,
|
||||
'red': $red-500,
|
||||
'yellow': $yellow-500,
|
||||
'green': $green-500,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--nbx-color-mode-toggle-color: #{$primary};
|
||||
--nbx-sidenav-link-color: #{$gray-800};
|
||||
--nbx-sidenav-pin-color: #{$orange};
|
||||
--nbx-sidenav-parent-color: #{$gray-900};
|
||||
--nbx-sidenav-parent-color: #{$gray-800};
|
||||
--nbx-sidenav-group-color: #{$gray-800};
|
||||
|
||||
&[data-netbox-color-mode='dark'] {
|
||||
@@ -49,7 +49,7 @@
|
||||
--nbx-color-mode-toggle-color: #{$yellow-300};
|
||||
--nbx-sidenav-link-color: #{$gray-200};
|
||||
--nbx-sidenav-pin-color: #{$yellow};
|
||||
--nbx-sidenav-parent-color: #{$gray-100};
|
||||
--nbx-sidenav-parent-color: #{$gray-200};
|
||||
--nbx-sidenav-group-color: #{$gray-600};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
lang="en"
|
||||
data-netbox-url-name="{{ request.resolver_match.url_name }}"
|
||||
data-netbox-base-path="{{ settings.BASE_PATH }}"
|
||||
{% if preferences|get_key:'ui.colormode' == 'dark'%}
|
||||
data-netbox-color-mode="dark"
|
||||
{% else %}
|
||||
data-netbox-color-mode="light"
|
||||
{% endif %}
|
||||
{% with preferences|get_key:'ui.colormode' as color_mode %}
|
||||
{% if color_mode == 'dark'%}
|
||||
data-netbox-color-mode="dark"
|
||||
{% elif color_mode == 'light' %}
|
||||
data-netbox-color-mode="light"
|
||||
{% else %}
|
||||
data-netbox-color-mode="unset"
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -23,33 +27,77 @@
|
||||
<title>{% block title %}Home{% endblock %} | NetBox</title>
|
||||
|
||||
<script type="text/javascript">
|
||||
/**
|
||||
* Set the color mode on the `<html/>` element and in local storage.
|
||||
*
|
||||
* @param mode {"dark" | "light"} NetBox Color Mode.
|
||||
* @param inferred {boolean} Value is inferred from browser/system preference.
|
||||
*/
|
||||
function setMode(mode, inferred) {
|
||||
document.documentElement.setAttribute("data-netbox-color-mode", mode);
|
||||
localStorage.setItem("netbox-color-mode", mode);
|
||||
localStorage.setItem("netbox-color-mode-inferred", inferred);
|
||||
}
|
||||
/**
|
||||
* Determine the best initial color mode to use prior to rendering.
|
||||
*/
|
||||
(function() {
|
||||
// Browser prefers dark color scheme.
|
||||
var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// Browser prefers light color scheme.
|
||||
var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
// Client NetBox color-mode override.
|
||||
var clientMode = localStorage.getItem('netbox-color-mode');
|
||||
// NetBox server-rendered value.
|
||||
var serverMode = document.documentElement.getAttribute('data-netbox-color-mode');
|
||||
|
||||
if ((clientMode !== null) && (clientMode !== serverMode)) {
|
||||
// If the client mode is set, use its value over the server's value.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', clientMode);
|
||||
}
|
||||
if (preferDark && serverMode === 'light') {
|
||||
// If the client value matches the server value, the browser preferrs dark-mode, but
|
||||
// the server value doesn't match the browser preference, use dark mode.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', 'dark');
|
||||
}
|
||||
if (preferLight && serverMode === 'dark') {
|
||||
// If the client value matches the server value, the browser preferrs dark-mode, but
|
||||
// the server value doesn't match the browser preference, use light mode.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', 'light');
|
||||
}
|
||||
(function () {
|
||||
try {
|
||||
// Browser prefers dark color scheme.
|
||||
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
// Browser prefers light color scheme.
|
||||
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
// Client NetBox color-mode override.
|
||||
var clientMode = localStorage.getItem("netbox-color-mode");
|
||||
// NetBox server-rendered value.
|
||||
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
|
||||
// Color mode is inferred from browser/system preference and not deterministically set by
|
||||
// the client or server.
|
||||
var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
|
||||
|
||||
if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
|
||||
// The color mode was previously inferred from browser/system preference, but
|
||||
// the server now has a value, so we should use the server's value.
|
||||
return setMode(serverMode, false);
|
||||
}
|
||||
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
|
||||
// If the client mode is not set but the server mode is, use the server mode.
|
||||
return setMode(serverMode, false);
|
||||
}
|
||||
if (clientMode !== null && serverMode === "unset") {
|
||||
// The color mode has been set, deterministically or otherwise, and the server
|
||||
// has no preference or has not been set. Use the client mode, but allow it to
|
||||
/// be overridden by the server if/when a server value exists.
|
||||
return setMode(clientMode, true);
|
||||
}
|
||||
if (
|
||||
clientMode !== null &&
|
||||
(serverMode === "light" || serverMode === "dark") &&
|
||||
clientMode !== serverMode
|
||||
) {
|
||||
// If the client mode is set and is different than the server mode (which is also set),
|
||||
// use the client mode over the server mode, as it should be more recent.
|
||||
return setMode(clientMode, false);
|
||||
}
|
||||
if (clientMode === serverMode) {
|
||||
// If the client and server modes match, use that value.
|
||||
return setMode(clientMode, false);
|
||||
}
|
||||
if (preferDark && serverMode === "unset") {
|
||||
// If the server mode is not set but the browser prefers dark mode, use dark mode, but
|
||||
// allow it to be overridden by an explicit preference.
|
||||
return setMode("dark", true);
|
||||
}
|
||||
if (preferLight && serverMode === "unset") {
|
||||
// If the server mode is not set but the browser prefers light mode, use light mode,
|
||||
// but allow it to be overridden by an explicit preference.
|
||||
return setMode("light", true);
|
||||
}
|
||||
} catch (error) {
|
||||
// In the event of an error, log it to the console and set the mode to light mode.
|
||||
console.error(error);
|
||||
}
|
||||
return setMode("light", true);
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Circuit Termination</h5>
|
||||
</div>
|
||||
@@ -53,9 +53,8 @@
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Termination Details</h5>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<div class="row my-3">
|
||||
<div class="col col-md-5">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">
|
||||
A Side
|
||||
</h5>
|
||||
<h5 class="card-header offset-sm-3">A Side</h5>
|
||||
<div class="card-body">
|
||||
{% if termination_a.device %}
|
||||
{# Device component #}
|
||||
@@ -100,9 +98,7 @@
|
||||
</div>
|
||||
<div class="col col-md-5">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header">
|
||||
B Side
|
||||
</h5>
|
||||
<h5 class="card-header offset-sm-3">B Side</h5>
|
||||
<div class="card-body">
|
||||
{% if tabs %}
|
||||
<ul class="nav nav-tabs">
|
||||
@@ -154,7 +150,7 @@
|
||||
<div class="row my-3 justify-content-center">
|
||||
<div class="col col-md-8">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Cable</h5>
|
||||
<h5 class="card-header offset-sm-3">Cable</h5>
|
||||
<div class="card-body">
|
||||
{% include 'dcim/inc/cable_form.html' %}
|
||||
</div>
|
||||
|
||||
@@ -290,22 +290,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
<div class="card noprint">
|
||||
<h5 class="card-header">
|
||||
Related Devices
|
||||
|
||||
@@ -4,111 +4,104 @@
|
||||
{% block form %}
|
||||
{% render_errors form %}
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Device</h5>
|
||||
</div>
|
||||
{% render_field form.name %}
|
||||
{% render_field form.device_role %}
|
||||
{% render_field form.tags %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Device</h5>
|
||||
</div>
|
||||
{% render_field form.name %}
|
||||
{% render_field form.device_role %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Hardware</h5>
|
||||
</div>
|
||||
{% render_field form.manufacturer %}
|
||||
{% render_field form.device_type %}
|
||||
{% render_field form.serial %}
|
||||
{% render_field form.asset_tag %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Hardware</h5>
|
||||
</div>
|
||||
{% render_field form.manufacturer %}
|
||||
{% render_field form.device_type %}
|
||||
{% render_field form.serial %}
|
||||
{% render_field form.asset_tag %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Location</h5>
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Location</h5>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.location %}
|
||||
{% render_field form.rack %}
|
||||
|
||||
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">Parent Device</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.location %}
|
||||
{% render_field form.rack %}
|
||||
|
||||
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">Parent Device</label>
|
||||
<div class="col">
|
||||
<input class="form-control" value="{{ obj.parent_bay.device }}" disabled />
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">Parent Bay</label>
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
|
||||
<i class="mdi mdi-close-thick"></i> Remove
|
||||
</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label">Parent Bay</label>
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
<input class="form-control" value="{{ obj.parent_bay.name }}" disabled />
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=obj.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
|
||||
<i class="mdi mdi-close-thick"></i> Remove
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% render_field form.face %}
|
||||
{% render_field form.position %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% render_field form.face %}
|
||||
{% render_field form.position %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Management</h5>
|
||||
</div>
|
||||
{% render_field form.status %}
|
||||
{% render_field form.platform %}
|
||||
{% if obj.pk %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
{% endif %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Management</h5>
|
||||
</div>
|
||||
{% render_field form.status %}
|
||||
{% render_field form.platform %}
|
||||
{% if obj.pk %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Virtualization</h5>
|
||||
</div>
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Virtualization</h5>
|
||||
</div>
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Tenancy</h5>
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Tenancy</h5>
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group my-4">
|
||||
<h5 class="text-center">Local Config Context Data</h5>
|
||||
{% render_field form.local_context_data %}
|
||||
<div class="field-group my-5">
|
||||
<h5 class="text-center">Local Config Context Data</h5>
|
||||
{% render_field form.local_context_data %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
{% render_field form.comments label='Comments' %}
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
</div>
|
||||
<div class="row my-3">
|
||||
<div class="col col-md-12">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<ul class="nav nav-pills mb-1" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-target="#interfaces" role="tab" data-bs-toggle="tab">
|
||||
Interfaces {% badge interface_table.rows|length %}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
</div>
|
||||
{% render_field form.tags %}
|
||||
{% if form.custom_fields %}
|
||||
<hr />
|
||||
<div class="field-group">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Interface</h5>
|
||||
</div>
|
||||
@@ -27,9 +27,8 @@
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.mark_connected %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">802.1Q Switching</h5>
|
||||
</div>
|
||||
@@ -40,8 +39,7 @@
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<hr />
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
<tr>
|
||||
<th scope="row">Racks</th>
|
||||
<td>
|
||||
{% if rack_count %}
|
||||
<div class="float-end noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ object.pk }}" class="btn btn-sm btn-primary" title="View elevations">
|
||||
<i class="mdi mdi-server"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:rack_list' %}?location_id={{ object.pk }}">{{ rack_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -59,22 +66,7 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:location_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<a href="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Virtual Machines</th>
|
||||
<td>
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform_id={{ object.pk }}">{{ virtualmachine_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
<button class="btn btn-sm btn-outline-primary toggle-images" selected="selected">
|
||||
<i class="mdi mdi-file-image-outline"></i>
|
||||
Hide Images
|
||||
</button>
|
||||
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}{% endif %}" class="btn btn-sm btn-primary{% if not prev_rack %} disabled{% endif %}">
|
||||
<i class="mdi mdi-chevron-left" aria-hidden="true"></i> Previous
|
||||
</a>
|
||||
@@ -210,22 +206,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:rack_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Reservations
|
||||
@@ -286,6 +267,13 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-12 col-xl-7">
|
||||
<div class="text-end mb-4">
|
||||
<select class="btn btn-sm btn-outline-dark rack-view">
|
||||
<option value="images-and-labels" selected="selected">Images and Labels</option>
|
||||
<option value="images-only">Images only</option>
|
||||
<option value="labels-only">Labels only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Rack</h5>
|
||||
</div>
|
||||
@@ -15,9 +15,8 @@
|
||||
{% render_field form.role %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Inventory Control</h5>
|
||||
</div>
|
||||
@@ -25,18 +24,16 @@
|
||||
{% render_field form.serial %}
|
||||
{% render_field form.asset_tag %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Tenancy</h5>
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Dimensions</h5>
|
||||
</div>
|
||||
@@ -45,34 +42,33 @@
|
||||
{% render_field form.u_height %}
|
||||
<div class="row mb-3">
|
||||
<label class="col col-md-3 col-form-label text-lg-end">Outer Dimensions</label>
|
||||
<div class="col col-md-3">
|
||||
<div class="col col-md-3 mb-1">
|
||||
{{ form.outer_width }}
|
||||
<div class="form-text">Width</div>
|
||||
</div>
|
||||
<div class="col col-md-3">
|
||||
<div class="col col-md-3 mb-1">
|
||||
{{ form.outer_depth }}
|
||||
<div class="form-text">Depth</div>
|
||||
</div>
|
||||
<div class="col col-md-3">
|
||||
<div class="col col-md-3 mb-1">
|
||||
{{ form.outer_unit }}
|
||||
<div class="form-text">Unit</div>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.desc_units %}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group my-4">
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group my-4">
|
||||
{% render_field form.comments label='Comments' %}
|
||||
<div class="field-group my-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user