mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 15:47:46 -06:00
Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c60670dc | ||
|
|
f2f36c67f6 | ||
|
|
281934cf34 | ||
|
|
00d72f18cf | ||
|
|
b36afdc924 | ||
|
|
4ed45e4031 | ||
|
|
cf0258204f | ||
|
|
3bd560add8 | ||
|
|
9e51a8d9d2 | ||
|
|
f59c6699f6 | ||
|
|
80f5eeacdd | ||
|
|
b1da374df2 | ||
|
|
dc1da0a738 | ||
|
|
4623858849 | ||
|
|
9c5891f1b6 | ||
|
|
d5538c1ca3 | ||
|
|
90f15b8d55 | ||
|
|
4e27e8d3dd | ||
|
|
3a89a676cd | ||
|
|
0885333b11 | ||
|
|
c287641363 | ||
|
|
de9646d096 | ||
|
|
dd2520d675 | ||
|
|
3a5914827b | ||
|
|
cf55e96241 | ||
|
|
bd29d15814 | ||
|
|
d3911e2a4c | ||
|
|
eb591731ef | ||
|
|
e40e2550a6 | ||
|
|
bfda5d9011 | ||
|
|
62a80c46a8 | ||
|
|
ceec1055e0 | ||
|
|
540bba4544 | ||
|
|
44c248e6c2 | ||
|
|
3a62fd49e6 | ||
|
|
a2007a4728 | ||
|
|
316c3808f7 | ||
|
|
928d880f0e | ||
|
|
c6930e3ea8 | ||
|
|
564884a774 | ||
|
|
7401fd7050 | ||
|
|
4a95cfd1c4 | ||
|
|
cd8943144b | ||
|
|
8400509358 | ||
|
|
d971131198 | ||
|
|
5729a06348 | ||
|
|
d59d23e308 | ||
|
|
3d1501e8fd | ||
|
|
c854c29016 | ||
|
|
33d8f8e5e7 | ||
|
|
93e241e8f3 | ||
|
|
43da786016 | ||
|
|
271d524687 | ||
|
|
4ebcdd2b8f | ||
|
|
2af8891f70 | ||
|
|
4e39021b6f | ||
|
|
2cd5fce62d | ||
|
|
ade307bc03 | ||
|
|
c8be4ef8e2 | ||
|
|
816214361d | ||
|
|
d1970ca85b | ||
|
|
8001694a4c | ||
|
|
10e258739f | ||
|
|
f3fdf03661 | ||
|
|
44814f759c | ||
|
|
4f5caa5ed2 | ||
|
|
aa7f04bf1b | ||
|
|
aaf1ea52b7 | ||
|
|
7990cfb078 | ||
|
|
a25ee66150 | ||
|
|
867af61875 | ||
|
|
8f4fa065f9 | ||
|
|
edb5220228 | ||
|
|
18332bdbf1 | ||
|
|
f1a7bceef2 | ||
|
|
eac2ace80b | ||
|
|
174ba6cf0f | ||
|
|
658c9347f3 | ||
|
|
7b3ef2ade5 | ||
|
|
2a62b628cf | ||
|
|
d8c07abd68 | ||
|
|
8d486c5838 | ||
|
|
eb91934d70 | ||
|
|
01654765e8 | ||
|
|
4c504870e0 | ||
|
|
3d687a6c2d | ||
|
|
96c4696417 | ||
|
|
e7659a5f99 | ||
|
|
53c9c3cf8d | ||
|
|
f60312febf | ||
|
|
7505baf3a1 | ||
|
|
33c6142365 | ||
|
|
10e874039f | ||
|
|
060ee2dd96 | ||
|
|
43d1182b4b | ||
|
|
d53da57f63 | ||
|
|
028b4b7ea7 | ||
|
|
4cb0230878 | ||
|
|
2fe8df3cbb | ||
|
|
64d67e3b00 | ||
|
|
aaf829898b | ||
|
|
8481cf66e3 | ||
|
|
bb150379a2 | ||
|
|
cc811e5a56 | ||
|
|
a9e583a693 | ||
|
|
3a3ff474cb | ||
|
|
cc00789d35 | ||
|
|
689f11a573 | ||
|
|
ae90ad1fb7 | ||
|
|
56d9725c39 | ||
|
|
1c69bfaf2c | ||
|
|
5e37f82b2f | ||
|
|
bdefd8ea8c | ||
|
|
eabd405845 | ||
|
|
03946f2ca8 | ||
|
|
fec8d1bc2f | ||
|
|
53f5f46037 | ||
|
|
b227757b9a | ||
|
|
eef5cefb5d | ||
|
|
7712b81ab9 | ||
|
|
7feb86fe55 | ||
|
|
d1efbf6620 | ||
|
|
aabee05a6a | ||
|
|
cf062b5b6a | ||
|
|
0b6a3898fe | ||
|
|
517ebcfbcd | ||
|
|
9ef24d3f43 | ||
|
|
02ffc2ddee | ||
|
|
62820ea2b8 | ||
|
|
04738587e8 | ||
|
|
cbbfcd0e7b | ||
|
|
309a70df89 | ||
|
|
4cb6984a65 | ||
|
|
3c32c09a5a | ||
|
|
2d9852d6f1 | ||
|
|
05542324fc | ||
|
|
669e86f96e | ||
|
|
cbf928f363 | ||
|
|
43b18c13e3 | ||
|
|
dda193247a | ||
|
|
2463e4efd3 | ||
|
|
a0b17887fd | ||
|
|
96784640e3 | ||
|
|
b75d12fe05 | ||
|
|
5e389c32ed | ||
|
|
fd89ef04b6 | ||
|
|
abcc10e938 | ||
|
|
3ad337dd15 | ||
|
|
a527767caa | ||
|
|
39129ecedf | ||
|
|
c97d2d4fe9 | ||
|
|
7735634649 | ||
|
|
148c6a6c23 | ||
|
|
360172cad0 | ||
|
|
75c91232b4 | ||
|
|
0190c0225e | ||
|
|
86d366be4d | ||
|
|
71d71a6b1b | ||
|
|
695ad47fe9 | ||
|
|
1b62c11db5 | ||
|
|
83a66a672d | ||
|
|
30b9ddc251 | ||
|
|
4a9831bd23 | ||
|
|
59388d89a0 | ||
|
|
1d033bd286 | ||
|
|
935f008c16 | ||
|
|
b5d57f3418 | ||
|
|
05b17a0082 | ||
|
|
91fd25a548 | ||
|
|
e05696dfcc | ||
|
|
157a45b627 | ||
|
|
e9a91455e8 | ||
|
|
f97eb99950 | ||
|
|
c4b7ab067a | ||
|
|
7477f6584e | ||
|
|
7b4f5252f1 | ||
|
|
4e03419e85 | ||
|
|
4d97043e26 | ||
|
|
c335b76ec6 | ||
|
|
356ff457be | ||
|
|
833c3fbd39 | ||
|
|
ace66eab61 | ||
|
|
ea9d2e3f88 | ||
|
|
211a1394d3 | ||
|
|
44032ffc11 | ||
|
|
f10460d774 | ||
|
|
57365ef7b9 | ||
|
|
d24f10ce6e | ||
|
|
1daa2ff98d | ||
|
|
0e6a6b784d | ||
|
|
e3576e2614 | ||
|
|
0899d7aefd | ||
|
|
d078befd33 | ||
|
|
f67cb71dbc | ||
|
|
721cd578bb | ||
|
|
3aac62caa7 | ||
|
|
6b9eb57de7 | ||
|
|
2b2a41edd2 | ||
|
|
cd1ad452da | ||
|
|
77868a9b17 | ||
|
|
59a2a43473 | ||
|
|
385a0f979e | ||
|
|
770cc5a700 | ||
|
|
a03155432e | ||
|
|
d51e833bf3 | ||
|
|
c6644ec1ae | ||
|
|
a7562a6aa1 | ||
|
|
6b70436e2b | ||
|
|
3dbc7bdd2c | ||
|
|
b4877e7fac | ||
|
|
f489ffa043 | ||
|
|
211c7641c1 | ||
|
|
51d066a1bc | ||
|
|
b702822857 | ||
|
|
1cbb2320c1 | ||
|
|
1c4ad3f817 | ||
|
|
ef5ec06141 | ||
|
|
0c37236d60 | ||
|
|
def853e8c4 | ||
|
|
7826cfb01f | ||
|
|
ac8f0a7ef2 | ||
|
|
48a907ae45 | ||
|
|
2fe620df70 | ||
|
|
536bd37d05 | ||
|
|
d29d265b0a |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.2
|
||||
placeholder: v3.3.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
14
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
@@ -19,11 +19,15 @@ body:
|
||||
label: Area
|
||||
description: To what section of the documentation does this change primarily pertain?
|
||||
options:
|
||||
- Installation instructions
|
||||
- Configuration parameters
|
||||
- Functionality/features
|
||||
- REST API
|
||||
- Administration/development
|
||||
- Features
|
||||
- Installation/upgrade
|
||||
- Getting started
|
||||
- Configuration
|
||||
- Customization
|
||||
- Integrations/API
|
||||
- Plugins
|
||||
- Administration
|
||||
- Development
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
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.3.2
|
||||
placeholder: v3.3.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,13 +1,14 @@
|
||||
<!--
|
||||
Thank you for your interest in contributing to NetBox! Please note that
|
||||
our contribution policy requires that a feature request or bug report be
|
||||
approved and assigned prior to filing a pull request. This helps avoid
|
||||
wasting time and effort on something that we might not be able to accept.
|
||||
approved and assigned prior to opening a pull request. This helps avoid
|
||||
waste time and effort on a proposed change that we might not be able to
|
||||
accept.
|
||||
|
||||
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
|
||||
TO YOU, IT WE BE CLOSED AUTOMATICALLY.
|
||||
TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
|
||||
|
||||
Specify your assigned issue number on the line below.
|
||||
Please specify your assigned issue number on the line below.
|
||||
-->
|
||||
### Fixes: #1234
|
||||
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,5 +1,7 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
16
.github/workflows/lock.yml
vendored
16
.github/workflows/lock.yml
vendored
@@ -4,18 +4,18 @@ name: 'Lock threads'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2
|
||||
- uses: dessant/lock-threads@v3
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: '90'
|
||||
issue-exclude-created-before: ''
|
||||
issue-exclude-labels: ''
|
||||
issue-lock-labels: ''
|
||||
issue-lock-comment: ''
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
issue-lock-reason: 'resolved'
|
||||
process-only: 'issues'
|
||||
|
||||
9
.github/workflows/stale.yml
vendored
9
.github/workflows/stale.yml
vendored
@@ -1,14 +1,21 @@
|
||||
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
|
||||
name: 'Close stale issues/PRs'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
- uses: actions/stale@v6
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
14
README.md
14
README.md
@@ -2,8 +2,6 @@
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By
|
||||
combining the traditional disciplines of IP address management (IPAM) and
|
||||
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
||||
@@ -11,6 +9,16 @@ NetBox provides the ideal "source of truth" to power network automation.
|
||||
Available as open source software under the Apache 2.0 license, NetBox is
|
||||
employed by thousands of organizations around the world.
|
||||
|
||||

|
||||
|
||||
[](https://github.com/netbox-community/netbox/commits)
|
||||
[](https://github.com/netbox-community/netbox/issues)
|
||||
[](https://github.com/netbox-community/netbox/pulls)
|
||||
[](https://github.com/netbox-community/netbox/graphs/contributors)
|
||||
<br />Stats via [Repography](https://repography.com)
|
||||
|
||||
## About NetBox
|
||||
|
||||

|
||||
|
||||
Myriad infrastructure components can be modeled in NetBox, including:
|
||||
@@ -57,7 +65,7 @@ complete list of requirements, see `requirements.txt`. The code is available
|
||||
[on GitHub](https://github.com/netbox-community/netbox).
|
||||
|
||||
<div align="center">
|
||||
<h4>Thank you to our sponsors!</h4>
|
||||
<h3>Thank you to our sponsors!</h3>
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ drf-yasg[validation]
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django
|
||||
graphene_django
|
||||
graphene_django<3.0
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://gunicorn.org/
|
||||
@@ -80,7 +80,8 @@ Jinja2
|
||||
|
||||
# Simple markup language for rendering HTML
|
||||
# https://github.com/Python-Markdown/markdown
|
||||
Markdown
|
||||
# mkdocs currently requires Markdown v3.3
|
||||
Markdown<3.4
|
||||
|
||||
# File inclusion plugin for Python-Markdown
|
||||
# https://github.com/cmacmackin/markdown-include
|
||||
|
||||
4
docs/_theme/main.html
vendored
4
docs/_theme/main.html
vendored
@@ -2,8 +2,8 @@
|
||||
|
||||
{% block site_meta %}
|
||||
{{ super() }}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs #}
|
||||
{% if not config.extra.readthedocs %}
|
||||
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
|
||||
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
|
||||
<meta name="robots" content="noindex">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
|
||||
|
||||
Default: None
|
||||
|
||||
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
|
||||
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
|
||||
|
||||
```python
|
||||
HTTP_PROXIES = {
|
||||
|
||||
@@ -129,6 +129,19 @@ The Script object provides a set of convenient functions for recording messages
|
||||
|
||||
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
|
||||
|
||||
## Change Logging
|
||||
|
||||
To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.
|
||||
|
||||
```python
|
||||
if obj.pk and hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
|
||||
obj.property = "New Value"
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
```
|
||||
|
||||
## Variable Reference
|
||||
|
||||
### Default Options
|
||||
|
||||
@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||
if console_port.connected_endpoint is None:
|
||||
if not console_port.connected_endpoints:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
|
||||
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
if power_port.connected_endpoints:
|
||||
connected_ports += 1
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
|
||||
@@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
!!! warning "Python 3.8 or later required"
|
||||
NetBox v3.2 requires Python 3.8, 3.9, or 3.10.
|
||||
NetBox requires Python 3.8, 3.9, or 3.10.
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
@@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
|
||||
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
|
||||
|
||||
```no-highlight
|
||||
sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
|
||||
### General Server Configuration
|
||||
|
||||
!!! info
|
||||
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
|
||||
When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
|
||||
|
||||
```python
|
||||
import ldap
|
||||
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = True
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
```
|
||||
|
||||
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
# Upgrading to a New NetBox Release
|
||||
|
||||
## Review the Release Notes
|
||||
Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade.
|
||||
|
||||
NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change).
|
||||
|
||||
[](../media/installation/upgrade_paths.png)
|
||||
|
||||
!!! warning "Perform a Backup"
|
||||
Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
|
||||
|
||||
## 1. Review the Release Notes
|
||||
|
||||
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
|
||||
|
||||
## Update Dependencies to Required Versions
|
||||
## 2. Update Dependencies to Required Versions
|
||||
|
||||
NetBox v3.0 and later require the following:
|
||||
|
||||
@@ -14,7 +23,7 @@ NetBox v3.0 and later require the following:
|
||||
| PostgreSQL | 10 |
|
||||
| Redis | 4.0 |
|
||||
|
||||
## Install the Latest Release
|
||||
## 3. Install the Latest Release
|
||||
|
||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
|
||||
|
||||
@@ -87,7 +96,7 @@ sudo git pull origin master
|
||||
|
||||
sudo git checkout v2.11.11
|
||||
|
||||
## Run the Upgrade Script
|
||||
## 4. Run the Upgrade Script
|
||||
|
||||
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
|
||||
|
||||
@@ -118,7 +127,7 @@ This script performs the following actions:
|
||||
been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
|
||||
intentionally modifying the database schema.
|
||||
|
||||
## Restart the NetBox Services
|
||||
## 5. Restart the NetBox Services
|
||||
|
||||
!!! warning
|
||||
If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
|
||||
@@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services:
|
||||
sudo systemctl restart netbox netbox-rq
|
||||
```
|
||||
|
||||
## Verify Housekeeping Scheduling
|
||||
## 6. 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 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.)
|
||||
|
||||
|
||||
BIN
docs/media/installation/upgrade_paths.png
Normal file
BIN
docs/media/installation/upgrade_paths.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
|
||||
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
|
||||
|
||||
::: utilities.forms.ColorField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CommentField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.JSONField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.MACAddressField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.SlugField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Choice Fields
|
||||
|
||||
::: utilities.forms.ChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.MultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Dynamic Object Fields
|
||||
|
||||
::: utilities.forms.DynamicModelChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.DynamicModelMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Content Type Fields
|
||||
|
||||
::: utilities.forms.ContentTypeChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.ContentTypeMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## CSV Import Fields
|
||||
|
||||
::: utilities.forms.CSVChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVModelChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVContentTypeField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVMultipleContentTypeField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
@@ -32,11 +32,11 @@ schema = MyQuery
|
||||
NetBox provides two object type classes for use by plugins.
|
||||
|
||||
::: netbox.graphql.types.BaseObjectType
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.graphql.types.NetBoxObjectType
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## GraphQL Fields
|
||||
@@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
|
||||
NetBox provides two field classes for use by plugins.
|
||||
|
||||
::: netbox.graphql.fields.ObjectField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.graphql.fields.ObjectListField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
@@ -112,6 +112,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
|
||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||
|
||||
!!! tip "Accessing Config Parameters"
|
||||
Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example:
|
||||
|
||||
```python
|
||||
from django.conf import settings
|
||||
settings.PLUGINS_CONFIG['myplugin']['verbose_name']
|
||||
```
|
||||
|
||||
## Create setup.py
|
||||
|
||||
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
|
||||
|
||||
@@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u
|
||||
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
||||
|
||||
::: netbox.tables.BooleanColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ChoiceFieldColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColorColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColoredLabelColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypeColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypesColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.MarkdownColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TagColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- __init__
|
||||
|
||||
@@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
|
||||
::: netbox.views.generic.base.BaseObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
- get_template_name
|
||||
|
||||
::: netbox.views.generic.ObjectEditView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
- alter_object
|
||||
|
||||
::: netbox.views.generic.ObjectDeleteView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
|
||||
::: netbox.views.generic.ObjectChildrenView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_children
|
||||
- prep_table_data
|
||||
@@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
::: netbox.views.generic.base.BaseMultiObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectListView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_table
|
||||
- export_table
|
||||
- export_template
|
||||
|
||||
::: netbox.views.generic.BulkImportView
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkEditView
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
@@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||
|
||||
::: netbox.views.generic.ObjectChangeLogView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
::: netbox.views.generic.ObjectJournalView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
|
||||
@@ -1,5 +1,166 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3.9 (2022-11-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
|
||||
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
|
||||
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
|
||||
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
|
||||
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
|
||||
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
|
||||
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
|
||||
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
|
||||
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
|
||||
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
|
||||
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
|
||||
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
|
||||
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
|
||||
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
|
||||
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
|
||||
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
|
||||
|
||||
---
|
||||
|
||||
## v3.3.8 (2022-11-16)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
|
||||
* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
|
||||
* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
|
||||
* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
|
||||
* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
|
||||
* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
|
||||
* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
|
||||
* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
|
||||
* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
|
||||
* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
|
||||
* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
|
||||
* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
|
||||
* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
|
||||
* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
|
||||
* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
|
||||
|
||||
---
|
||||
|
||||
## v3.3.7 (2022-11-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions
|
||||
* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users
|
||||
* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API
|
||||
* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments
|
||||
* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API
|
||||
|
||||
---
|
||||
|
||||
## v3.3.6 (2022-10-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
|
||||
* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
|
||||
* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
|
||||
* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
|
||||
* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
|
||||
* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
|
||||
* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
|
||||
* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
|
||||
* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
|
||||
* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
|
||||
* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
|
||||
* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests
|
||||
* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
|
||||
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
|
||||
* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
|
||||
* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
|
||||
* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
|
||||
* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
|
||||
* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
|
||||
|
||||
---
|
||||
|
||||
## v3.3.5 (2022-10-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8424](https://github.com/netbox-community/netbox/issues/8424) - Include rack elevation under device view
|
||||
* [#10352](https://github.com/netbox-community/netbox/issues/10352) - Omit extraneous URL query attributes during search
|
||||
* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
|
||||
* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
|
||||
* [#10423](https://github.com/netbox-community/netbox/issues/10423) - Enforce object type validation when creating journal entries
|
||||
* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
|
||||
* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
|
||||
* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
|
||||
* [#10460](https://github.com/netbox-community/netbox/issues/10460) - Restore missing connection details for device components
|
||||
* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
|
||||
* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
|
||||
* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
|
||||
* [#10491](https://github.com/netbox-community/netbox/issues/10491) - Clarify representation of blocking contact assignments during contact deletion
|
||||
* [#10513](https://github.com/netbox-community/netbox/issues/10513) - Disable the reassignment of a module to a new device
|
||||
* [#10517](https://github.com/netbox-community/netbox/issues/10517) - Automatically inherit site assignment from cluster when creating a virtual machine
|
||||
* [#10559](https://github.com/netbox-community/netbox/issues/10559) - Permit the pinning of a VM to a particular device within a cluster which has no site assignment
|
||||
* [#10562](https://github.com/netbox-community/netbox/issues/10562) - Correct URL for contacts table tags column
|
||||
|
||||
---
|
||||
|
||||
## v3.3.4 (2022-09-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI
|
||||
* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface
|
||||
|
||||
---
|
||||
|
||||
## v3.3.3 (2022-09-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected`
|
||||
* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types
|
||||
* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI
|
||||
* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters
|
||||
* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components
|
||||
* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables
|
||||
* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules
|
||||
* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed
|
||||
* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services
|
||||
* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments
|
||||
* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field
|
||||
* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links
|
||||
* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API
|
||||
* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection
|
||||
* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import
|
||||
* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails
|
||||
* [#10353](https://github.com/netbox-community/netbox/issues/10353) - Table action buttons should reserve return URL parameters
|
||||
* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination
|
||||
|
||||
---
|
||||
|
||||
## v3.3.2 (2022-09-02)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -30,7 +30,7 @@ plugins:
|
||||
- os.chdir('netbox/')
|
||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
- django.setup()
|
||||
rendering:
|
||||
options:
|
||||
heading_level: 3
|
||||
members_order: source
|
||||
show_root_heading: true
|
||||
@@ -38,7 +38,6 @@ plugins:
|
||||
show_root_toc_entry: false
|
||||
show_source: false
|
||||
extra:
|
||||
readthedocs: !ENV READTHEDOCS
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/netbox-community/netbox
|
||||
|
||||
@@ -76,6 +76,12 @@ class ProviderNetworkForm(NetBoxModelForm):
|
||||
class CircuitTypeForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Circuit Type', (
|
||||
'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from circuits.models import *
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .columns import CommitRateColumn
|
||||
|
||||
__all__ = (
|
||||
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Circuit ID'
|
||||
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from circuits.models import *
|
||||
from django_tables2.utils import Accessor
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
__all__ = (
|
||||
@@ -10,7 +11,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderTable(NetBoxTable):
|
||||
class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
|
||||
@@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
|
||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
|
||||
)
|
||||
Circuit.objects.bulk_create(circuits)
|
||||
|
||||
@@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
|
||||
))
|
||||
CircuitTermination.objects.bulk_create(circuit_terminations)
|
||||
|
||||
@@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
def test_term_side(self):
|
||||
params = {'term_side': 'A'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
|
||||
def test_port_speed(self):
|
||||
params = {'port_speed': ['1000', '2000']}
|
||||
@@ -397,11 +399,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_provider_network(self):
|
||||
provider_networks = ProviderNetwork.objects.all()[:2]
|
||||
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
|
||||
|
||||
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
||||
region = NestedRegionSerializer(required=False, allow_null=True)
|
||||
group = NestedSiteGroupSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneSerializerField(required=False)
|
||||
time_zone = TimeZoneSerializerField(required=False, allow_null=True)
|
||||
asns = SerializedPKRelatedField(
|
||||
queryset=ASN.objects.all(),
|
||||
serializer=NestedASNSerializer,
|
||||
@@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
|
||||
class VirtualChassisSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False)
|
||||
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
|
||||
# Ethernet Backplane
|
||||
TYPE_1GE_KX = '1000base-kx'
|
||||
TYPE_10GE_KR = '10gbase-kr'
|
||||
TYPE_10GE_KX4 = '10gbase-kx4'
|
||||
TYPE_25GE_KR = '25gbase-kr'
|
||||
TYPE_40GE_KR4 = '40gbase-kr4'
|
||||
TYPE_50GE_KR = '50gbase-kr'
|
||||
TYPE_100GE_KP4 = '100gbase-kp4'
|
||||
TYPE_100GE_KR2 = '100gbase-kr2'
|
||||
TYPE_100GE_KR4 = '100gbase-kr4'
|
||||
|
||||
# Wireless
|
||||
TYPE_80211A = 'ieee802.11a'
|
||||
TYPE_80211G = 'ieee802.11g'
|
||||
@@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Ethernet (backplane)',
|
||||
(
|
||||
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
|
||||
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
|
||||
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
|
||||
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
|
||||
(TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
|
||||
(TYPE_50GE_KR, '50GBASE-KR (50GE)'),
|
||||
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
|
||||
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
|
||||
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Wireless',
|
||||
(
|
||||
@@ -1096,7 +1121,7 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
|
||||
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
|
||||
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
|
||||
(PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
has_front_image = django_filters.BooleanFilter(
|
||||
label='Has a front image',
|
||||
method='_has_front_image'
|
||||
)
|
||||
has_rear_image = django_filters.BooleanFilter(
|
||||
label='Has a rear image',
|
||||
method='_has_rear_image'
|
||||
)
|
||||
console_ports = django_filters.BooleanFilter(
|
||||
method='_console_ports',
|
||||
label='Has console ports',
|
||||
@@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
def _has_front_image(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.exclude(front_image='')
|
||||
else:
|
||||
return queryset.filter(front_image='')
|
||||
|
||||
def _has_rear_image(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.exclude(rear_image='')
|
||||
else:
|
||||
return queryset.filter(rear_image='')
|
||||
|
||||
def _console_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleporttemplates__isnull=value)
|
||||
|
||||
@@ -780,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
device_type = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__slug',
|
||||
queryset=DeviceType.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Device type (slug)',
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=DeviceType.objects.all(),
|
||||
label='Device type (ID)',
|
||||
@@ -1144,6 +1170,15 @@ class CabledObjectFilterSet(django_filters.FilterSet):
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
occupied = django_filters.BooleanFilter(
|
||||
method='filter_occupied'
|
||||
)
|
||||
|
||||
def filter_occupied(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True))
|
||||
else:
|
||||
return queryset.filter(cable__isnull=True, mark_connected=False)
|
||||
|
||||
|
||||
class PathEndpointFilterSet(django_filters.FilterSet):
|
||||
@@ -1328,7 +1363,7 @@ class InterfaceFilterSet(
|
||||
try:
|
||||
devices = Device.objects.filter(pk__in=id_list)
|
||||
for device in devices:
|
||||
vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
|
||||
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
@@ -3,7 +3,7 @@ from django import forms
|
||||
from dcim.models import *
|
||||
from extras.forms import CustomFieldsMixin
|
||||
from extras.models import Tag
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
|
||||
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
|
||||
from .object_create import ComponentCreateForm
|
||||
|
||||
__all__ = (
|
||||
@@ -24,7 +24,7 @@ __all__ = (
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
|
||||
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
replication_fields = ('name', 'label')
|
||||
|
||||
|
||||
class ConsolePortBulkCreateForm(
|
||||
@@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm(
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = ConsolePort
|
||||
field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags')
|
||||
|
||||
|
||||
class ConsoleServerPortBulkCreateForm(
|
||||
@@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm(
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = ConsoleServerPort
|
||||
field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'type', 'speed', 'description', 'tags')
|
||||
|
||||
|
||||
class PowerPortBulkCreateForm(
|
||||
@@ -60,7 +61,7 @@ class PowerPortBulkCreateForm(
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = PowerPort
|
||||
field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
|
||||
|
||||
|
||||
class PowerOutletBulkCreateForm(
|
||||
@@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm(
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = PowerOutlet
|
||||
field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
|
||||
|
||||
|
||||
class InterfaceBulkCreateForm(
|
||||
@@ -79,7 +80,7 @@ class InterfaceBulkCreateForm(
|
||||
):
|
||||
model = Interface
|
||||
field_order = (
|
||||
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
|
||||
'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
|
||||
'poe_type', 'mark_connected', 'description', 'tags',
|
||||
)
|
||||
|
||||
@@ -96,13 +97,13 @@ class RearPortBulkCreateForm(
|
||||
DeviceBulkAddComponentForm
|
||||
):
|
||||
model = RearPort
|
||||
field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
|
||||
|
||||
|
||||
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = ModuleBay
|
||||
field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
|
||||
|
||||
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
position_pattern = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
@@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
|
||||
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
|
||||
model = DeviceBay
|
||||
field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
|
||||
field_order = ('name', 'label', 'description', 'tags')
|
||||
|
||||
|
||||
class InventoryItemBulkCreateForm(
|
||||
@@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm(
|
||||
):
|
||||
model = InventoryItem
|
||||
field_order = (
|
||||
'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'description', 'tags',
|
||||
)
|
||||
|
||||
@@ -1218,7 +1218,7 @@ class FrontPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class RearPortBulkEditForm(
|
||||
@@ -1229,7 +1229,7 @@ class RearPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class ModuleBayBulkEditForm(
|
||||
|
||||
@@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
|
||||
label='Power Feed',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
|
||||
'power_panel_id': f'$termination_{cable_end}_powerpanel',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
|
||||
('Images', ('has_front_image', 'has_rear_image')),
|
||||
('Components', (
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
||||
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
|
||||
@@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
choices=add_blank_choice(DeviceAirflowChoices),
|
||||
required=False
|
||||
)
|
||||
has_front_image = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a front image',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
has_rear_image = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a rear image',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
console_ports = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has console ports',
|
||||
@@ -936,12 +951,37 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortFilterForm(DeviceComponentFilterForm):
|
||||
class CabledFilterForm(forms.Form):
|
||||
cabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
occupied = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PathEndpointFilterForm(CabledFilterForm):
|
||||
connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsolePort
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -954,12 +994,13 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -972,12 +1013,13 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPortFilterForm(DeviceComponentFilterForm):
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -986,12 +1028,13 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(DeviceComponentFilterForm):
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -1000,7 +1043,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
@@ -1009,6 +1052,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
kind = MultipleChoiceField(
|
||||
choices=InterfaceKindChoices,
|
||||
@@ -1089,11 +1133,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FrontPortFilterForm(DeviceComponentFilterForm):
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
type = MultipleChoiceField(
|
||||
@@ -1106,12 +1151,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RearPortFilterForm(DeviceComponentFilterForm):
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
|
||||
@@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Region', (
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = (
|
||||
@@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Site Group', (
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SiteGroup
|
||||
fields = (
|
||||
@@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
class RackRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Rack Role', (
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = [
|
||||
@@ -340,6 +358,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
class ManufacturerForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Manufacturer', (
|
||||
'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
@@ -373,6 +397,7 @@ class DeviceTypeForm(NetBoxModelForm):
|
||||
'front_image', 'rear_image', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'airflow': StaticSelect(),
|
||||
'subdevice_role': StaticSelect(),
|
||||
'front_image': ClearableFileInput(attrs={
|
||||
'accept': DEVICETYPE_IMAGE_FORMATS
|
||||
@@ -405,6 +430,12 @@ class ModuleTypeForm(NetBoxModelForm):
|
||||
class DeviceRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Device Role', (
|
||||
'name', 'slug', 'color', 'vm_role', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = [
|
||||
@@ -421,6 +452,13 @@ class PlatformForm(NetBoxModelForm):
|
||||
max_length=64
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Platform', (
|
||||
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@@ -678,6 +716,7 @@ class ModuleForm(NetBoxModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
self.fields['device'].disabled = True
|
||||
self.fields['replicate_components'].initial = False
|
||||
self.fields['replicate_components'].disabled = True
|
||||
self.fields['adopt_components'].initial = False
|
||||
@@ -838,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm):
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'location_id': '$location',
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
@@ -849,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Power Panel', ('region', 'site', 'power_panel')),
|
||||
('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
|
||||
('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
|
||||
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
|
||||
'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
|
||||
'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
@@ -986,47 +1036,85 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
# Device component templates
|
||||
#
|
||||
|
||||
class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all()
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable reassignment of DeviceType when editing an existing instance
|
||||
if self.instance.pk:
|
||||
self.fields['device_type'].disabled = True
|
||||
|
||||
|
||||
class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all().all(),
|
||||
required=False
|
||||
)
|
||||
module_type = DynamicModelChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable reassignment of ModuleType when editing an existing instance
|
||||
if self.instance.pk:
|
||||
self.fields['module_type'].disabled = True
|
||||
|
||||
|
||||
class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
|
||||
)
|
||||
|
||||
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect,
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect,
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class PowerPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class PowerOutletTemplateForm(ModularComponentTemplateForm):
|
||||
power_port = DynamicModelChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
required=False,
|
||||
@@ -1035,35 +1123,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'feed_leg': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')),
|
||||
('PoE', ('poe_mode', 'poe_type'))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'poe_mode': StaticSelect(),
|
||||
'poe_type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class FrontPortTemplateForm(ModularComponentTemplateForm):
|
||||
rear_port = DynamicModelChoiceField(
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
required=False,
|
||||
@@ -1073,6 +1166,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||
'description',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
@@ -1080,48 +1180,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
'module_type': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class ModuleBayTemplateForm(ComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'name', 'label', 'position', 'description')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBayTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'label', 'position', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class DeviceBayTemplateForm(ComponentTemplateForm):
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'name', 'label', 'description')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = [
|
||||
'device_type', 'name', 'label', 'description',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
class InventoryItemTemplateForm(ComponentTemplateForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItemTemplate.objects.all(),
|
||||
required=False,
|
||||
@@ -1148,22 +1250,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
widget=forms.HiddenInput
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
|
||||
'component_type', 'component_id',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItemTemplate
|
||||
fields = [
|
||||
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
|
||||
'component_type', 'component_id',
|
||||
]
|
||||
widgets = {
|
||||
'device_type': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortForm(NetBoxModelForm):
|
||||
class DeviceComponentForm(NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all()
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable reassignment of Device when editing an existing instance
|
||||
if self.instance.pk:
|
||||
self.fields['device'].disabled = True
|
||||
|
||||
|
||||
class ModularDeviceComponentForm(DeviceComponentForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
@@ -1172,25 +1291,31 @@ class ConsolePortForm(NetBoxModelForm):
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortForm(ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'speed': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
class ConsoleServerPortForm(ModularDeviceComponentForm):
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1199,42 +1324,32 @@ class ConsoleServerPortForm(NetBoxModelForm):
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'speed': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class PowerPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
class PowerPortForm(ModularDeviceComponentForm):
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
|
||||
'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
|
||||
'description',
|
||||
'tags',
|
||||
'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
class PowerOutletForm(ModularDeviceComponentForm):
|
||||
power_port = DynamicModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False,
|
||||
@@ -1243,6 +1358,13 @@ class PowerOutletForm(NetBoxModelForm):
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
|
||||
'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
@@ -1250,20 +1372,12 @@ class PowerOutletForm(NetBoxModelForm):
|
||||
'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'feed_leg': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
@@ -1331,8 +1445,14 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
label='VRF'
|
||||
)
|
||||
|
||||
wwn = forms.CharField(
|
||||
empty_value=None,
|
||||
required=False,
|
||||
label='WWN'
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
|
||||
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
@@ -1352,7 +1472,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
'speed': SelectSpeedWidget(),
|
||||
'poe_mode': StaticSelect(),
|
||||
@@ -1371,25 +1490,8 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
'rf_channel_width': "Populated by selected channel (if set)",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Restrict LAG/bridge interface assignment by device/VC
|
||||
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
|
||||
device = Device.objects.filter(pk=device_id).first()
|
||||
if device and device.virtual_chassis and device.virtual_chassis.master:
|
||||
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
|
||||
|
||||
|
||||
class FrontPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
class FrontPortForm(ModularDeviceComponentForm):
|
||||
rear_port = DynamicModelChoiceField(
|
||||
queryset=RearPort.objects.all(),
|
||||
query_params={
|
||||
@@ -1397,6 +1499,13 @@ class FrontPortForm(NetBoxModelForm):
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
|
||||
'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
@@ -1404,18 +1513,15 @@ class FrontPortForm(NetBoxModelForm):
|
||||
'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class RearPortForm(NetBoxModelForm):
|
||||
module = DynamicModelChoiceField(
|
||||
queryset=Module.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
class RearPortForm(ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -1424,33 +1530,32 @@ class RearPortForm(NetBoxModelForm):
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
'type': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayForm(NetBoxModelForm):
|
||||
class ModuleBayForm(DeviceComponentForm):
|
||||
fieldsets = (
|
||||
(None, ('device', 'name', 'label', 'position', 'description', 'tags',)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'device', 'name', 'label', 'position', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayForm(NetBoxModelForm):
|
||||
class DeviceBayForm(DeviceComponentForm):
|
||||
fieldsets = (
|
||||
(None, ('device', 'name', 'label', 'description', 'tags',)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'device', 'name', 'label', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
|
||||
|
||||
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
@@ -1473,10 +1578,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class InventoryItemForm(NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all()
|
||||
)
|
||||
class InventoryItemForm(DeviceComponentForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=InventoryItem.objects.all(),
|
||||
required=False,
|
||||
@@ -1523,6 +1625,12 @@ class InventoryItemForm(NetBoxModelForm):
|
||||
class InventoryItemRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Inventory Item Role', (
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
fields = [
|
||||
|
||||
@@ -2,46 +2,56 @@ from django import forms
|
||||
|
||||
from dcim.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from utilities.forms import (
|
||||
BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
|
||||
)
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from . import models as model_forms
|
||||
|
||||
__all__ = (
|
||||
'ComponentTemplateCreateForm',
|
||||
'DeviceComponentCreateForm',
|
||||
'ComponentCreateForm',
|
||||
'ConsolePortCreateForm',
|
||||
'ConsolePortTemplateCreateForm',
|
||||
'ConsoleServerPortCreateForm',
|
||||
'ConsoleServerPortTemplateCreateForm',
|
||||
'DeviceBayCreateForm',
|
||||
'DeviceBayTemplateCreateForm',
|
||||
'FrontPortCreateForm',
|
||||
'FrontPortTemplateCreateForm',
|
||||
'InterfaceCreateForm',
|
||||
'InterfaceTemplateCreateForm',
|
||||
'InventoryItemCreateForm',
|
||||
'ModularComponentTemplateCreateForm',
|
||||
'InventoryItemTemplateCreateForm',
|
||||
'ModuleBayCreateForm',
|
||||
'ModuleBayTemplateCreateForm',
|
||||
'PowerOutletCreateForm',
|
||||
'PowerOutletTemplateCreateForm',
|
||||
'PowerPortCreateForm',
|
||||
'PowerPortTemplateCreateForm',
|
||||
'RearPortCreateForm',
|
||||
'RearPortTemplateCreateForm',
|
||||
'VirtualChassisCreateForm',
|
||||
)
|
||||
|
||||
|
||||
class ComponentCreateForm(BootstrapMixin, forms.Form):
|
||||
class ComponentCreateForm(forms.Form):
|
||||
"""
|
||||
Subclass this form when facilitating the creation of one or more device component or component templates based on
|
||||
Subclass this form when facilitating the creation of one or more component or component template objects based on
|
||||
a name pattern.
|
||||
"""
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
label_pattern = ExpandableNameField(
|
||||
label='Label',
|
||||
name = ExpandableNameField()
|
||||
label = ExpandableNameField(
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
|
||||
)
|
||||
|
||||
# Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by
|
||||
# ComponentCreateView when creating objects.
|
||||
replication_fields = ('name', 'label')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that all patterned fields generate an equal number of values
|
||||
patterned_fields = [
|
||||
field_name for field_name in self.fields if field_name.endswith('_pattern')
|
||||
]
|
||||
pattern_count = len(self.cleaned_data['name_pattern'])
|
||||
for field_name in patterned_fields:
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
pattern_count = len(self.cleaned_data[self.replication_fields[0]])
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
@@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
class ComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned only to a DeviceType.
|
||||
"""
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
)
|
||||
field_order = ('device_type', 'name_pattern', 'label_pattern')
|
||||
#
|
||||
# Device component templates
|
||||
#
|
||||
|
||||
class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm):
|
||||
|
||||
class Meta(model_forms.ConsolePortTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class ModularComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
|
||||
"""
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name',
|
||||
help_text="""
|
||||
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
|
||||
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
|
||||
the module bay position.
|
||||
"""
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False
|
||||
)
|
||||
module_type = DynamicModelChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False
|
||||
)
|
||||
field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern')
|
||||
class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm):
|
||||
|
||||
class Meta(model_forms.ConsoleServerPortTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class DeviceComponentCreateForm(ComponentCreateForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all()
|
||||
)
|
||||
field_order = ('device', 'name_pattern', 'label_pattern')
|
||||
class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm):
|
||||
|
||||
class Meta(model_forms.PowerPortTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
|
||||
rear_port_set = forms.MultipleChoiceField(
|
||||
class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm):
|
||||
|
||||
class Meta(model_forms.PowerOutletTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
|
||||
|
||||
class Meta(model_forms.InterfaceTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
|
||||
rear_port = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='Rear ports',
|
||||
help_text='Select one rear port assignment for each front port being created.',
|
||||
)
|
||||
field_order = (
|
||||
'device_type', 'name_pattern', 'label_pattern', 'rear_port_set',
|
||||
|
||||
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
|
||||
fieldsets = (
|
||||
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')),
|
||||
)
|
||||
|
||||
class Meta(model_forms.FrontPortTemplateForm.Meta):
|
||||
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
|
||||
choices.append(
|
||||
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
|
||||
)
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
|
||||
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
|
||||
|
||||
return {
|
||||
'rear_port': int(rear_port),
|
||||
@@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
|
||||
}
|
||||
|
||||
|
||||
class FrontPortCreateForm(DeviceComponentCreateForm):
|
||||
rear_port_set = forms.MultipleChoiceField(
|
||||
class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm):
|
||||
|
||||
class Meta(model_forms.RearPortTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm):
|
||||
|
||||
class Meta(model_forms.DeviceBayTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm):
|
||||
position = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
|
||||
)
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
|
||||
class Meta(model_forms.ModuleBayTemplateForm.Meta):
|
||||
exclude = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm):
|
||||
|
||||
class Meta(model_forms.InventoryItemTemplateForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm):
|
||||
|
||||
class Meta(model_forms.ConsolePortForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm):
|
||||
|
||||
class Meta(model_forms.ConsoleServerPortForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm):
|
||||
|
||||
class Meta(model_forms.PowerPortForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
|
||||
|
||||
class Meta(model_forms.PowerOutletForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
|
||||
|
||||
class Meta(model_forms.InterfaceForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'module' in self.fields:
|
||||
self.fields['name'].help_text += ' The string <code>{module}</code> will be replaced with the position ' \
|
||||
'of the assigned module, if any'
|
||||
|
||||
|
||||
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
rear_port = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label='Rear ports',
|
||||
help_text='Select one rear port assignment for each front port being created.',
|
||||
)
|
||||
field_order = (
|
||||
'device', 'name_pattern', 'label_pattern', 'rear_port_set',
|
||||
|
||||
# Override fieldsets from FrontPortForm to omit rear_port_position
|
||||
fieldsets = (
|
||||
(None, (
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta(model_forms.FrontPortForm.Meta):
|
||||
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
|
||||
choices.append(
|
||||
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
|
||||
)
|
||||
self.fields['rear_port_set'].choices = choices
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
|
||||
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
|
||||
|
||||
return {
|
||||
'rear_port': int(rear_port),
|
||||
@@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
|
||||
position_pattern = ExpandableNameField(
|
||||
class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm):
|
||||
|
||||
class Meta(model_forms.RearPortForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm):
|
||||
|
||||
class Meta(model_forms.DeviceBayForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm):
|
||||
position = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
|
||||
)
|
||||
field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
|
||||
replication_fields = ('name', 'label', 'position')
|
||||
|
||||
class Meta(model_forms.ModuleBayForm.Meta):
|
||||
exclude = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class ModuleBayCreateForm(DeviceComponentCreateForm):
|
||||
position_pattern = ExpandableNameField(
|
||||
label='Position',
|
||||
required=False,
|
||||
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
|
||||
)
|
||||
field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
|
||||
class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm):
|
||||
|
||||
class Meta(model_forms.InventoryItemForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
|
||||
class InventoryItemCreateForm(ComponentCreateForm):
|
||||
# Device is assigned by the model form
|
||||
field_order = ('name_pattern', 'label_pattern')
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
|
||||
@@ -279,17 +279,24 @@ class CableTermination(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check for existing termination
|
||||
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
|
||||
termination_type=self.termination_type,
|
||||
termination_id=self.termination_id
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError({
|
||||
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
|
||||
})
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
|
||||
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
|
||||
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
|
||||
raise ValidationError({
|
||||
'termination': "Circuit terminations attached to a provider network may not be cabled."
|
||||
})
|
||||
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -574,6 +581,7 @@ class CablePath(models.Model):
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.provider_network)],
|
||||
])
|
||||
is_complete = True
|
||||
break
|
||||
elif circuit_termination.site and not circuit_termination.cable:
|
||||
# Circuit terminates to a Site
|
||||
|
||||
@@ -189,7 +189,7 @@ class PathEndpoint(models.Model):
|
||||
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
|
||||
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
|
||||
|
||||
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
@@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
raise ValidationError({
|
||||
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
|
||||
})
|
||||
if hasattr(self, 'rear_port'):
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError({
|
||||
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
|
||||
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
|
||||
})
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
raise ValidationError({
|
||||
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
|
||||
})
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError({
|
||||
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
|
||||
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
|
||||
})
|
||||
|
||||
|
||||
class RearPort(ModularComponentModel, CabledObjectModel):
|
||||
|
||||
@@ -987,6 +987,14 @@ class Module(NetBoxModel, ConfigContextModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:module', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.module_bay.device != self.device:
|
||||
raise ValidationError(
|
||||
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
|
||||
|
||||
@@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
|
||||
max_length=200
|
||||
)
|
||||
|
||||
clone_fields = ('rack', 'user', 'tenant')
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
cablepath.retrace()
|
||||
|
||||
|
||||
@receiver(post_save, sender=FrontPort)
|
||||
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
|
||||
"""
|
||||
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
|
||||
"""
|
||||
if created and not raw:
|
||||
rearport = instance.rear_port
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
|
||||
cablepath.retrace()
|
||||
|
||||
@@ -35,7 +35,7 @@ class Node(Hyperlink):
|
||||
"""
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, **extra):
|
||||
super(Node, self).__init__(href=url, target='_blank', **extra)
|
||||
super(Node, self).__init__(href=url, target='_parent', **extra)
|
||||
|
||||
x, y = position
|
||||
|
||||
@@ -166,7 +166,7 @@ class CableTraceSVG:
|
||||
"""
|
||||
if hasattr(instance, 'parent_object'):
|
||||
# Termination
|
||||
return 'f0f0f0'
|
||||
return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
|
||||
if hasattr(instance, 'device_role'):
|
||||
# Device
|
||||
return instance.device_role.color
|
||||
|
||||
@@ -9,6 +9,7 @@ from svgwrite.text import Text
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Q
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
@@ -41,7 +42,7 @@ def get_device_description(device):
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
device.device_type.u_height,
|
||||
floatformat(device.device_type.u_height),
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
|
||||
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
Device,
|
||||
DeviceBay,
|
||||
DeviceRole,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
InventoryItemRole,
|
||||
ModuleBay,
|
||||
Platform,
|
||||
PowerOutlet,
|
||||
PowerPort,
|
||||
RearPort,
|
||||
VirtualChassis,
|
||||
)
|
||||
from django_tables2.utils import Accessor
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
@@ -137,12 +151,21 @@ class PlatformTable(NetBoxTable):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
region = tables.Column(
|
||||
accessor=Accessor('site__region'),
|
||||
linkify=True
|
||||
)
|
||||
site_group = tables.Column(
|
||||
accessor=Accessor('site__group'),
|
||||
linkify=True,
|
||||
verbose_name='Site Group'
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -152,6 +175,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
position = columns.TemplateColumn(
|
||||
template_code='{{ value|floatformat }}'
|
||||
)
|
||||
device_role = columns.ColoredLabelColumn(
|
||||
verbose_name='Role'
|
||||
)
|
||||
@@ -189,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
)
|
||||
@@ -199,10 +222,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
|
||||
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
|
||||
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
ConsolePortTemplate,
|
||||
ConsoleServerPortTemplate,
|
||||
DeviceBayTemplate,
|
||||
DeviceType,
|
||||
FrontPortTemplate,
|
||||
InterfaceTemplate,
|
||||
InventoryItemTemplate,
|
||||
Manufacturer,
|
||||
ModuleBayTemplate,
|
||||
PowerOutletTemplate,
|
||||
PowerPortTemplate,
|
||||
RearPortTemplate,
|
||||
)
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
|
||||
__all__ = (
|
||||
@@ -27,7 +39,7 @@ __all__ = (
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerTable(NetBoxTable):
|
||||
class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -43,9 +55,6 @@ class ManufacturerTable(NetBoxTable):
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:manufacturer_list'
|
||||
)
|
||||
@@ -85,6 +94,9 @@ class DeviceTypeTable(NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:devicetype_list'
|
||||
)
|
||||
u_height = columns.TemplateColumn(
|
||||
template_code='{{ value|floatformat }}'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = DeviceType
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import PowerFeed, PowerPanel
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
from .devices import CableTerminationTable
|
||||
|
||||
__all__ = (
|
||||
@@ -14,7 +16,7 @@ __all__ = (
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelTable(NetBoxTable):
|
||||
class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerpanel_list'
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from django_tables2.utils import Accessor
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
@@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
order_by=('_name',),
|
||||
linkify=True
|
||||
@@ -68,9 +68,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .template_code import LOCATION_BUTTONS
|
||||
|
||||
__all__ = (
|
||||
@@ -17,7 +18,7 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionTable(NetBoxTable):
|
||||
class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
|
||||
url_params={'region_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:region_list'
|
||||
)
|
||||
@@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupTable(NetBoxTable):
|
||||
class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:sitegroup_list'
|
||||
)
|
||||
@@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name='ASN Count'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
)
|
||||
@@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
|
||||
@@ -239,7 +239,7 @@ INTERFACE_BUTTONS = """
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name_pattern={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_l2vpntermination %}
|
||||
<li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>
|
||||
|
||||
@@ -2057,6 +2057,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
cls.bulk_update_data = {
|
||||
'domain': 'newdomain',
|
||||
'master': None
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertTrue(CablePath.objects.first().is_complete)
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
|
||||
@@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'),
|
||||
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR),
|
||||
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT),
|
||||
)
|
||||
@@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_is_full_depth(self):
|
||||
params = {'is_full_depth': 'true'}
|
||||
params = {'is_full_depth': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'is_full_depth': 'false'}
|
||||
params = {'is_full_depth': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_subdevice_role(self):
|
||||
@@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_has_front_image(self):
|
||||
params = {'has_front_image': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'has_front_image': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_has_rear_image(self):
|
||||
params = {'has_rear_image': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'has_rear_image': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_console_ports(self):
|
||||
params = {'console_ports': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -1631,6 +1643,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device_type': [device_types[0].slug, device_types[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_devicerole(self):
|
||||
device_roles = DeviceRole.objects.all()[:2]
|
||||
@@ -1983,12 +1997,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -2037,9 +2045,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
@@ -2144,12 +2164,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['First', 'Second']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -2198,9 +2212,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
@@ -2313,12 +2339,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'allocated_draw': [50, 100]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -2367,9 +2387,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
@@ -2478,12 +2510,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
@@ -2532,9 +2558,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
@@ -2741,12 +2779,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'label': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
@@ -2880,9 +2912,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_kind(self):
|
||||
@@ -3091,9 +3135,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
@@ -3255,9 +3305,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
@@ -4159,9 +4215,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_cabled(self):
|
||||
params = {'cabled': 'true'}
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'cabled': 'false'}
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_connected(self):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices
|
||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
from utilities.testing import create_test_device
|
||||
@@ -129,10 +129,11 @@ class LabelTestCase(TestCase):
|
||||
"""
|
||||
interface_data = {
|
||||
'device': self.device.pk,
|
||||
'name_pattern': 'eth[0-9]',
|
||||
'label_pattern': 'Interface[0-9]',
|
||||
'name': 'eth[0-9]',
|
||||
'label': 'Interface[0-9]',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
}
|
||||
form = DeviceComponentCreateForm(interface_data)
|
||||
form = InterfaceCreateForm(interface_data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
@@ -142,10 +143,11 @@ class LabelTestCase(TestCase):
|
||||
"""
|
||||
bad_interface_data = {
|
||||
'device': self.device.pk,
|
||||
'name_pattern': 'eth[0-9]',
|
||||
'label_pattern': 'Interface[0-1]',
|
||||
'name': 'eth[0-9]',
|
||||
'label': 'Interface[0-1]',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
}
|
||||
form = DeviceComponentCreateForm(bad_interface_data)
|
||||
form = InterfaceCreateForm(bad_interface_data)
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label_pattern', form.errors)
|
||||
self.assertIn('label', form.errors)
|
||||
|
||||
@@ -1082,31 +1082,28 @@ front-ports:
|
||||
|
||||
class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = ConsolePortTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
ConsolePortTemplate.objects.bulk_create((
|
||||
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
|
||||
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
|
||||
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
|
||||
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
|
||||
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
|
||||
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Console Port Template X',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Console Port Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Console Port Template [4-6]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
}
|
||||
|
||||
@@ -1117,31 +1114,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
|
||||
|
||||
class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = ConsoleServerPortTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
ConsoleServerPortTemplate.objects.bulk_create((
|
||||
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
|
||||
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
|
||||
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
|
||||
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
|
||||
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
|
||||
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Console Server Port Template X',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Console Server Port Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Console Server Port Template [4-6]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
}
|
||||
|
||||
@@ -1152,24 +1146,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
|
||||
|
||||
class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = PowerPortTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
PowerPortTemplate.objects.bulk_create((
|
||||
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
|
||||
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
|
||||
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Power Port Template X',
|
||||
'type': PowerPortTypeChoices.TYPE_IEC_C14,
|
||||
'maximum_draw': 100,
|
||||
@@ -1177,8 +1168,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Power Port Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Power Port Template [4-6]',
|
||||
'type': PowerPortTypeChoices.TYPE_IEC_C14,
|
||||
'maximum_draw': 100,
|
||||
'allocated_draw': 50,
|
||||
@@ -1193,6 +1184,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = PowerOutletTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1220,7 +1212,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetype.pk,
|
||||
'name_pattern': 'Power Outlet Template [4-6]',
|
||||
'name': 'Power Outlet Template [4-6]',
|
||||
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
|
||||
'power_port': powerports[0].pk,
|
||||
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
|
||||
@@ -1234,34 +1226,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
|
||||
|
||||
class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = InterfaceTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
InterfaceTemplate.objects.bulk_create((
|
||||
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
|
||||
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
|
||||
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
|
||||
InterfaceTemplate(device_type=devicetype, name='Interface Template 1'),
|
||||
InterfaceTemplate(device_type=devicetype, name='Interface Template 2'),
|
||||
InterfaceTemplate(device_type=devicetype, name='Interface Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Interface Template X',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mgmt_only': True,
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Interface Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Interface Template [4-6]',
|
||||
# Test that a label can be applied to each generated interface templates
|
||||
'label_pattern': 'Interface Template Label [3-5]',
|
||||
'label': 'Interface Template Label [3-5]',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mgmt_only': True,
|
||||
}
|
||||
@@ -1274,6 +1263,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = FrontPortTemplate
|
||||
validation_excluded_fields = ('name', 'label', 'rear_port')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1306,11 +1296,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetype.pk,
|
||||
'name_pattern': 'Front Port [4-6]',
|
||||
'name': 'Front Port [4-6]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port_set': [
|
||||
'{}:1'.format(rp.pk) for rp in rearports[3:6]
|
||||
],
|
||||
'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1320,32 +1308,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = RearPortTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
RearPortTemplate.objects.bulk_create((
|
||||
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
|
||||
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
|
||||
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
|
||||
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Rear Port Template X',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'positions': 2,
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Rear Port Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Rear Port Template [4-6]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'positions': 2,
|
||||
}
|
||||
@@ -1357,30 +1342,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
|
||||
|
||||
class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = ModuleBayTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
ModuleBayTemplate.objects.bulk_create((
|
||||
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'),
|
||||
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'),
|
||||
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
|
||||
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Module Bay Template X',
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Module Bay Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Module Bay Template [4-6]',
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1390,30 +1372,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
|
||||
|
||||
DeviceBayTemplate.objects.bulk_create((
|
||||
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
|
||||
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
|
||||
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
|
||||
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Device Bay Template X',
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Device Bay Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Device Bay Template [4-6]',
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1423,6 +1402,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
|
||||
class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = InventoryItemTemplate
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1431,30 +1411,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
|
||||
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
devicetypes = (
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(devicetypes)
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1')
|
||||
|
||||
inventory_item_templates = (
|
||||
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]),
|
||||
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]),
|
||||
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]),
|
||||
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]),
|
||||
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]),
|
||||
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]),
|
||||
)
|
||||
for item in inventory_item_templates:
|
||||
item.save()
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Inventory Item Template X',
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Inventory Item Template [4-6]',
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Inventory Item Template [4-6]',
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
}
|
||||
|
||||
@@ -1803,10 +1778,12 @@ class ModuleTestCase(
|
||||
ModuleBay(device=devices[0], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[0], name='Module Bay 3'),
|
||||
ModuleBay(device=devices[0], name='Module Bay 4'),
|
||||
ModuleBay(device=devices[0], name='Module Bay 5'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 3'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 4'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 5'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
@@ -1820,7 +1797,7 @@ class ModuleTestCase(
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': devices[1].pk,
|
||||
'device': devices[0].pk,
|
||||
'module_bay': module_bays[3].pk,
|
||||
'module_type': module_types[0].pk,
|
||||
'serial': 'A',
|
||||
@@ -1892,7 +1869,6 @@ class ModuleTestCase(
|
||||
self.assertIsNone(interface.module)
|
||||
|
||||
# Create a module with adopted components
|
||||
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
|
||||
form_data['module_type'] = module_type
|
||||
form_data['replicate_components'] = False
|
||||
form_data['adopt_components'] = True
|
||||
@@ -1912,6 +1888,7 @@ class ModuleTestCase(
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1935,9 +1912,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Console Port [4-6]',
|
||||
'name': 'Console Port [4-6]',
|
||||
# Test that a label can be applied to each generated console ports
|
||||
'label_pattern': 'Serial[3-5]',
|
||||
'label': 'Serial[3-5]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console port',
|
||||
'tags': sorted([t.pk for t in tags]),
|
||||
@@ -1970,6 +1947,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsoleServerPort
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1993,7 +1971,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Console Server Port [4-6]',
|
||||
'name': 'Console Server Port [4-6]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console server port',
|
||||
'tags': [t.pk for t in tags],
|
||||
@@ -2026,6 +2004,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = PowerPort
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2051,7 +2030,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Power Port [4-6]]',
|
||||
'name': 'Power Port [4-6]]',
|
||||
'type': PowerPortTypeChoices.TYPE_IEC_C14,
|
||||
'maximum_draw': 100,
|
||||
'allocated_draw': 50,
|
||||
@@ -2088,6 +2067,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = PowerOutlet
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2119,7 +2099,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Power Outlet [4-6]',
|
||||
'name': 'Power Outlet [4-6]',
|
||||
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
|
||||
'power_port': powerports[1].pk,
|
||||
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
|
||||
@@ -2153,6 +2133,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = Interface
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2217,7 +2198,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Interface [4-6]',
|
||||
'name': 'Interface [4-6]',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'enabled': False,
|
||||
'bridge': interfaces[4].pk,
|
||||
@@ -2277,6 +2258,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = FrontPort
|
||||
validation_excluded_fields = ('name', 'label', 'rear_port')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2312,11 +2294,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Front Port [4-6]',
|
||||
'name': 'Front Port [4-6]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port_set': [
|
||||
'{}:1'.format(rp.pk) for rp in rearports[3:6]
|
||||
],
|
||||
'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
|
||||
'description': 'New description',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@@ -2348,6 +2328,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = RearPort
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2372,7 +2353,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Rear Port [4-6]',
|
||||
'name': 'Rear Port [4-6]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'positions': 3,
|
||||
'description': 'A rear port',
|
||||
@@ -2406,6 +2387,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ModuleBay
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2428,7 +2410,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Module Bay [4-6]',
|
||||
'name': 'Module Bay [4-6]',
|
||||
'description': 'A module bay',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@@ -2447,6 +2429,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = DeviceBay
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2472,7 +2455,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Device Bay [4-6]',
|
||||
'name': 'Device Bay [4-6]',
|
||||
'description': 'A device bay',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@@ -2491,6 +2474,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = InventoryItem
|
||||
validation_excluded_fields = ('name', 'label')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2525,7 +2509,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Inventory Item [4-6]',
|
||||
'name': 'Inventory Item [4-6]',
|
||||
'role': roles[1].pk,
|
||||
'manufacturer': manufacturer.pk,
|
||||
'parent': None,
|
||||
|
||||
@@ -355,7 +355,7 @@ class SiteView(generic.ObjectView):
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
site=instance,
|
||||
position__isnull=True,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
@@ -450,7 +450,7 @@ class LocationView(generic.ObjectView):
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
position__isnull=True,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
@@ -589,17 +589,18 @@ class RackElevationListView(generic.ObjectListView):
|
||||
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Ordering
|
||||
ORDERING_CHOICES = {
|
||||
'name': 'Name (A-Z)',
|
||||
'-name': 'Name (Z-A)',
|
||||
'facility_id': 'Facility ID (A-Z)',
|
||||
'-facility_id': 'Facility ID (Z-A)',
|
||||
}
|
||||
sort = request.GET.get('sort', "name")
|
||||
sort = request.GET.get('sort', 'name')
|
||||
if sort not in ORDERING_CHOICES:
|
||||
sort = 'name'
|
||||
|
||||
racks = racks.order_by(sort)
|
||||
sort_field = sort.replace("name", "_name") # Use natural ordering
|
||||
racks = racks.order_by(sort_field)
|
||||
|
||||
# Pagination
|
||||
per_page = get_paginate_count(request)
|
||||
@@ -1120,9 +1121,8 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePortTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.ConsolePortTemplateCreateForm
|
||||
model_form = forms.ConsolePortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class ConsolePortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1155,9 +1155,8 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPortTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.ConsoleServerPortTemplateCreateForm
|
||||
model_form = forms.ConsoleServerPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1190,9 +1189,8 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PowerPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPortTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.PowerPortTemplateCreateForm
|
||||
model_form = forms.PowerPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class PowerPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1225,9 +1223,8 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutletTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.PowerOutletTemplateCreateForm
|
||||
model_form = forms.PowerOutletTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class PowerOutletTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1260,9 +1257,8 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class InterfaceTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InterfaceTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.InterfaceTemplateCreateForm
|
||||
model_form = forms.InterfaceTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class InterfaceTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1297,15 +1293,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = FrontPortTemplate.objects.all()
|
||||
form = forms.FrontPortTemplateCreateForm
|
||||
model_form = forms.FrontPortTemplateForm
|
||||
template_name = 'dcim/frontporttemplate_create.html'
|
||||
|
||||
def initialize_forms(self, request):
|
||||
form, model_form = super().initialize_forms(request)
|
||||
|
||||
model_form.fields.pop('rear_port')
|
||||
model_form.fields.pop('rear_port_position')
|
||||
|
||||
return form, model_form
|
||||
|
||||
|
||||
class FrontPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1338,9 +1325,8 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class RearPortTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPortTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.RearPortTemplateCreateForm
|
||||
model_form = forms.RearPortTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class RearPortTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1375,8 +1361,6 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = ModuleBayTemplate.objects.all()
|
||||
form = forms.ModuleBayTemplateCreateForm
|
||||
model_form = forms.ModuleBayTemplateForm
|
||||
template_name = 'dcim/modulebaytemplate_create.html'
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class ModuleBayTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1409,9 +1393,8 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class DeviceBayTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBayTemplate.objects.all()
|
||||
form = forms.ComponentTemplateCreateForm
|
||||
form = forms.DeviceBayTemplateCreateForm
|
||||
model_form = forms.DeviceBayTemplateForm
|
||||
template_name = 'dcim/component_template_create.html'
|
||||
|
||||
|
||||
class DeviceBayTemplateEditView(generic.ObjectEditView):
|
||||
@@ -1444,9 +1427,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItemTemplate.objects.all()
|
||||
form = forms.ModularComponentTemplateCreateForm
|
||||
form = forms.InventoryItemTemplateCreateForm
|
||||
model_form = forms.InventoryItemTemplateForm
|
||||
template_name = 'dcim/inventoryitemtemplate_create.html'
|
||||
|
||||
def alter_object(self, instance, request):
|
||||
# Set component (if any)
|
||||
@@ -1635,6 +1617,7 @@ class DeviceView(generic.ObjectView):
|
||||
return {
|
||||
'services': services,
|
||||
'vc_members': vc_members,
|
||||
'svg_extra': f'highlight=id:{instance.pk}'
|
||||
}
|
||||
|
||||
|
||||
@@ -1874,14 +1857,13 @@ class ConsolePortView(generic.ObjectView):
|
||||
|
||||
class ConsolePortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.ConsolePortCreateForm
|
||||
model_form = forms.ConsolePortForm
|
||||
|
||||
|
||||
class ConsolePortEditView(generic.ObjectEditView):
|
||||
queryset = ConsolePort.objects.all()
|
||||
form = forms.ConsolePortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsolePortDeleteView(generic.ObjectDeleteView):
|
||||
@@ -1933,14 +1915,13 @@ class ConsoleServerPortView(generic.ObjectView):
|
||||
|
||||
class ConsoleServerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.ConsoleServerPortCreateForm
|
||||
model_form = forms.ConsoleServerPortForm
|
||||
|
||||
|
||||
class ConsoleServerPortEditView(generic.ObjectEditView):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
form = forms.ConsoleServerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
|
||||
@@ -1992,14 +1973,13 @@ class PowerPortView(generic.ObjectView):
|
||||
|
||||
class PowerPortCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerPort.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.PowerPortCreateForm
|
||||
model_form = forms.PowerPortForm
|
||||
|
||||
|
||||
class PowerPortEditView(generic.ObjectEditView):
|
||||
queryset = PowerPort.objects.all()
|
||||
form = forms.PowerPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerPortDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2051,14 +2031,13 @@ class PowerOutletView(generic.ObjectView):
|
||||
|
||||
class PowerOutletCreateView(generic.ComponentCreateView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.PowerOutletCreateForm
|
||||
model_form = forms.PowerOutletForm
|
||||
|
||||
|
||||
class PowerOutletEditView(generic.ObjectEditView):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
form = forms.PowerOutletForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class PowerOutletDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2154,42 +2133,13 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
class InterfaceCreateView(generic.ComponentCreateView):
|
||||
queryset = Interface.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.InterfaceCreateForm
|
||||
model_form = forms.InterfaceForm
|
||||
# template_name = 'dcim/interface_create.html'
|
||||
|
||||
# TODO: Figure out what to do with this
|
||||
# def post(self, request):
|
||||
# """
|
||||
# Override inherited post() method to handle request to assign newly created
|
||||
# interface objects (first object) to an IP Address object.
|
||||
# """
|
||||
# form = self.form(request.POST, initial=request.GET)
|
||||
# new_objs = self.validate_form(request, form)
|
||||
#
|
||||
# if form.is_valid() and not form.errors:
|
||||
# if '_addanother' in request.POST:
|
||||
# return redirect(request.get_full_path())
|
||||
# elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
|
||||
# request.user.has_perm('ipam.add_ipaddress'):
|
||||
# first_obj = new_objs[0].pk
|
||||
# return redirect(
|
||||
# f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
|
||||
# )
|
||||
# else:
|
||||
# return redirect(self.get_return_url(request))
|
||||
#
|
||||
# return render(request, self.template_name, {
|
||||
# 'obj_type': self.queryset.model._meta.verbose_name,
|
||||
# 'form': form,
|
||||
# 'return_url': self.get_return_url(request),
|
||||
# })
|
||||
|
||||
|
||||
class InterfaceEditView(generic.ObjectEditView):
|
||||
queryset = Interface.objects.all()
|
||||
form = forms.InterfaceForm
|
||||
template_name = 'dcim/interface_edit.html'
|
||||
|
||||
|
||||
class InterfaceDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2244,19 +2194,10 @@ class FrontPortCreateView(generic.ComponentCreateView):
|
||||
form = forms.FrontPortCreateForm
|
||||
model_form = forms.FrontPortForm
|
||||
|
||||
def initialize_forms(self, request):
|
||||
form, model_form = super().initialize_forms(request)
|
||||
|
||||
model_form.fields.pop('rear_port')
|
||||
model_form.fields.pop('rear_port_position')
|
||||
|
||||
return form, model_form
|
||||
|
||||
|
||||
class FrontPortEditView(generic.ObjectEditView):
|
||||
queryset = FrontPort.objects.all()
|
||||
form = forms.FrontPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class FrontPortDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2308,14 +2249,13 @@ class RearPortView(generic.ObjectView):
|
||||
|
||||
class RearPortCreateView(generic.ComponentCreateView):
|
||||
queryset = RearPort.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.RearPortCreateForm
|
||||
model_form = forms.RearPortForm
|
||||
|
||||
|
||||
class RearPortEditView(generic.ObjectEditView):
|
||||
queryset = RearPort.objects.all()
|
||||
form = forms.RearPortForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class RearPortDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2369,13 +2309,11 @@ class ModuleBayCreateView(generic.ComponentCreateView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
form = forms.ModuleBayCreateForm
|
||||
model_form = forms.ModuleBayForm
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class ModuleBayEditView(generic.ObjectEditView):
|
||||
queryset = ModuleBay.objects.all()
|
||||
form = forms.ModuleBayForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class ModuleBayDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2423,14 +2361,13 @@ class DeviceBayView(generic.ObjectView):
|
||||
|
||||
class DeviceBayCreateView(generic.ComponentCreateView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
form = forms.DeviceComponentCreateForm
|
||||
form = forms.DeviceBayCreateForm
|
||||
model_form = forms.DeviceBayForm
|
||||
|
||||
|
||||
class DeviceBayEditView(generic.ObjectEditView):
|
||||
queryset = DeviceBay.objects.all()
|
||||
form = forms.DeviceBayForm
|
||||
template_name = 'dcim/device_component_edit.html'
|
||||
|
||||
|
||||
class DeviceBayDeleteView(generic.ObjectDeleteView):
|
||||
@@ -2552,7 +2489,6 @@ class InventoryItemCreateView(generic.ComponentCreateView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
form = forms.InventoryItemCreateForm
|
||||
model_form = forms.InventoryItemForm
|
||||
template_name = 'dcim/inventoryitem_create.html'
|
||||
|
||||
def alter_object(self, instance, request):
|
||||
# Set component (if any)
|
||||
@@ -2736,7 +2672,6 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
|
||||
#
|
||||
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
|
||||
"values."
|
||||
)
|
||||
|
||||
# Serialize object and multi-object values
|
||||
for cf in self._get_custom_fields():
|
||||
if cf.name in data and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
serializer_class = get_serializer_for_model(
|
||||
model=cf.object_type.model_class(),
|
||||
prefix=NESTED_SERIALIZER_PREFIX
|
||||
)
|
||||
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
@@ -159,7 +159,7 @@ class ReportViewSet(ViewSet):
|
||||
# Read the PK as "<module>.<report>"
|
||||
if '.' not in pk:
|
||||
raise Http404
|
||||
module_name, report_name = pk.split('.', 1)
|
||||
module_name, report_name = pk.split('.', maxsplit=1)
|
||||
|
||||
# Raise a 404 on an invalid Report module/name
|
||||
report = get_report(module_name, report_name)
|
||||
@@ -183,8 +183,8 @@ class ReportViewSet(ViewSet):
|
||||
}
|
||||
|
||||
# Iterate through all available Reports.
|
||||
for module_name, reports in get_reports():
|
||||
for report in reports:
|
||||
for module_name, reports in get_reports().items():
|
||||
for report in reports.values():
|
||||
|
||||
# Attach the relevant JobResult (if any) to each Report.
|
||||
report.result = results.get(report.full_name, None)
|
||||
@@ -257,7 +257,7 @@ class ScriptViewSet(ViewSet):
|
||||
lookup_value_regex = '[^/]+' # Allow dots
|
||||
|
||||
def _get_script(self, pk):
|
||||
module_name, script_name = pk.split('.')
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
script = get_script(module_name, script_name)
|
||||
if script is None:
|
||||
raise Http404
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||
|
||||
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
|
||||
from netbox import thread_locals
|
||||
from netbox.request_context import set_request
|
||||
from netbox.context import current_request, webhooks_queue
|
||||
from .webhooks import flush_webhooks
|
||||
|
||||
|
||||
@@ -16,27 +12,14 @@ def change_logging(request):
|
||||
|
||||
:param request: WSGIRequest object with a unique `id` set
|
||||
"""
|
||||
set_request(request)
|
||||
thread_locals.webhook_queue = []
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
current_request.set(request)
|
||||
webhooks_queue.set([])
|
||||
|
||||
yield
|
||||
|
||||
# Disconnect change logging signals. This is necessary to avoid recording any errant
|
||||
# changes during test cleanup.
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_webhooks(thread_locals.webhook_queue)
|
||||
del thread_locals.webhook_queue
|
||||
flush_webhooks(webhooks_queue.get())
|
||||
|
||||
# Clear the request from thread-local storage
|
||||
set_request(None)
|
||||
# Clear context vars
|
||||
current_request.set(None)
|
||||
webhooks_queue.set([])
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
|
||||
@@ -38,6 +38,10 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
required=False,
|
||||
help_text='Comma-separated list of field choices'
|
||||
)
|
||||
ui_visibility = CSVChoiceField(
|
||||
choices=CustomFieldVisibilityChoices,
|
||||
help_text='How the custom field is displayed in the user interface'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
|
||||
@@ -34,7 +34,9 @@ class CustomFieldsMixin:
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type)
|
||||
return CustomField.objects.filter(content_types=content_type).exclude(
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field()
|
||||
@@ -50,13 +52,6 @@ class CustomFieldsMixin:
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
self.fields[field_name].disabled = True
|
||||
if self.fields[field_name].help_text:
|
||||
self.fields[field_name].help_text += '<br />'
|
||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
||||
'Field is set to read-only.'
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[field_name] = customfield
|
||||
if customfield.group_name not in self.custom_field_groups:
|
||||
|
||||
@@ -21,8 +21,8 @@ class Command(BaseCommand):
|
||||
reports = get_reports()
|
||||
|
||||
# Run reports
|
||||
for module_name, report_list in reports:
|
||||
for report in report_list:
|
||||
for module_name, report_list in reports.items():
|
||||
for report in report_list.values():
|
||||
if module_name in options['reports'] or report.full_name in options['reports']:
|
||||
|
||||
# Run the report and create a new JobResult
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.utils.safestring import mark_safe
|
||||
from extras.choices import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
@@ -41,7 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
|
||||
class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
@@ -143,8 +143,14 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
verbose_name='UI visibility',
|
||||
help_text='Specifies the visibility of custom field in the UI'
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default',
|
||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
|
||||
@@ -291,12 +297,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
@@ -392,6 +399,12 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
field.disabled = True
|
||||
prepend = '<br />' if field.help_text else ''
|
||||
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
|
||||
|
||||
return field
|
||||
|
||||
def to_filter(self, lookup_expr=None):
|
||||
|
||||
@@ -21,7 +21,7 @@ from extras.conditions import ConditionSet
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import (
|
||||
CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
@@ -187,7 +187,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
return render_jinja2(self.payload_url, context)
|
||||
|
||||
|
||||
class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||
code to be rendered with an object as context.
|
||||
@@ -230,6 +230,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
help_text="Force link to open in a new window"
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
|
||||
@@ -459,6 +463,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:journalentry', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Prevent the creation of journal entries on unsupported models
|
||||
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
|
||||
if self.assigned_object_type not in permitted_types:
|
||||
raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
|
||||
|
||||
def get_kind_color(self):
|
||||
return JournalEntryKindChoices.colors.get(self.kind)
|
||||
|
||||
|
||||
@@ -26,20 +26,18 @@ def get_report(module_name, report_name):
|
||||
"""
|
||||
Return a specific report from within a module.
|
||||
"""
|
||||
file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
|
||||
reports = get_reports()
|
||||
module = reports.get(module_name)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except FileNotFoundError:
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
report = getattr(module, report_name, None)
|
||||
report = module.get(report_name)
|
||||
|
||||
if report is None:
|
||||
return None
|
||||
|
||||
return report()
|
||||
return report
|
||||
|
||||
|
||||
def get_reports():
|
||||
@@ -52,7 +50,7 @@ def get_reports():
|
||||
...
|
||||
]
|
||||
"""
|
||||
module_list = []
|
||||
module_list = {}
|
||||
|
||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
@@ -61,7 +59,16 @@ def get_reports():
|
||||
report_order = getattr(module, "report_order", ())
|
||||
ordered_reports = [cls() for cls in report_order if is_report(cls)]
|
||||
unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order]
|
||||
module_list.append((module_name, [*ordered_reports, *unordered_reports]))
|
||||
|
||||
module_reports = {}
|
||||
|
||||
for cls in [*ordered_reports, *unordered_reports]:
|
||||
# For reports in submodules use the full import path w/o the root module as the name
|
||||
report_name = cls.full_name.split(".", maxsplit=1)[1]
|
||||
module_reports[report_name] = cls
|
||||
|
||||
if module_reports:
|
||||
module_list[module_name] = module_reports
|
||||
|
||||
return module_list
|
||||
|
||||
|
||||
@@ -299,6 +299,10 @@ class BaseScript:
|
||||
def module(cls):
|
||||
return cls.__module__
|
||||
|
||||
@classmethod
|
||||
def root_module(cls):
|
||||
return cls.__module__.split(".")[0]
|
||||
|
||||
@classproperty
|
||||
def job_timeout(self):
|
||||
return getattr(self.Meta, 'job_timeout', None)
|
||||
@@ -514,7 +518,9 @@ def get_scripts(use_names=False):
|
||||
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
|
||||
# For scripts in submodules use the full import path w/o the root module as the name
|
||||
script_name = cls.full_name.split(".", maxsplit=1)[1]
|
||||
module_scripts[script_name] = cls
|
||||
if module_scripts:
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ from django.dispatch import receiver, Signal
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.validators import CustomValidator
|
||||
from netbox import thread_locals
|
||||
from netbox.config import get_config
|
||||
from netbox.request_context import get_request
|
||||
from netbox.context import current_request, webhooks_queue
|
||||
from netbox.signals import post_clean
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange
|
||||
@@ -23,22 +22,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
clear_webhooks = Signal()
|
||||
|
||||
|
||||
def is_same_object(instance, webhook_data, request_id):
|
||||
"""
|
||||
Compare the given instance to the most recent queued webhook object, returning True
|
||||
if they match. This check is used to avoid creating duplicate webhook entries.
|
||||
"""
|
||||
return (
|
||||
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
|
||||
instance.pk == webhook_data['object_id'] and
|
||||
request_id == webhook_data['request_id']
|
||||
)
|
||||
|
||||
|
||||
@receiver((post_save, m2m_changed))
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
m2m_changed = False
|
||||
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
request = get_request()
|
||||
m2m_changed = False
|
||||
|
||||
def is_same_object(instance, webhook_data):
|
||||
return (
|
||||
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
|
||||
instance.pk == webhook_data['object_id'] and
|
||||
request.id == webhook_data['request_id']
|
||||
)
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
return
|
||||
|
||||
# Determine the type of change being made
|
||||
if kwargs.get('created'):
|
||||
@@ -69,13 +78,14 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
|
||||
queue = webhooks_queue.get()
|
||||
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
|
||||
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
|
||||
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
|
||||
webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
queue[-1]['data'] = serialize_for_webhook(instance)
|
||||
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||
else:
|
||||
enqueue_object(webhook_queue, instance, request.user, request.id, action)
|
||||
enqueue_object(queue, instance, request.user, request.id, action)
|
||||
webhooks_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
@@ -84,39 +94,42 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
@receiver(pre_delete)
|
||||
def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
return
|
||||
|
||||
request = get_request()
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
|
||||
instance.snapshot()
|
||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
queue = webhooks_queue.get()
|
||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
webhooks_queue.set(queue)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
@receiver(clear_webhooks)
|
||||
def clear_webhook_queue(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
|
||||
"""
|
||||
logger = logging.getLogger('webhooks')
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
|
||||
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
|
||||
webhook_queue.clear()
|
||||
logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
|
||||
webhooks_queue.set([])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -803,6 +803,57 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
|
||||
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
|
||||
|
||||
def test_specify_related_object_by_attr(self):
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
vlans = VLAN.objects.all()[:3]
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
# Set related objects by PK
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': vlans[0].pk,
|
||||
'multiobject_field': [vlans[1].pk, vlans[2].pk],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
# Set related objects by name
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': {
|
||||
'name': vlans[0].name,
|
||||
},
|
||||
'multiobject_field': [
|
||||
{
|
||||
'name': vlans[1].name
|
||||
},
|
||||
{
|
||||
'name': vlans[2].name
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
def test_minimum_maximum_values_validation(self):
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
|
||||
from extras import models, views
|
||||
from netbox.views.generic import ObjectChangeLogView
|
||||
@@ -100,12 +100,12 @@ urlpatterns = [
|
||||
|
||||
# Reports
|
||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
|
||||
|
||||
# Scripts
|
||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
]
|
||||
|
||||
@@ -441,6 +441,12 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
def get_return_url(self, request, obj=None):
|
||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
return {
|
||||
'content_type': request.GET.get('content_type'),
|
||||
'object_id': request.GET.get('object_id'),
|
||||
}
|
||||
|
||||
|
||||
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
@@ -528,9 +534,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
}
|
||||
|
||||
ret = []
|
||||
for module, report_list in reports:
|
||||
|
||||
for module, report_list in reports.items():
|
||||
module_reports = []
|
||||
for report in report_list:
|
||||
for report in report_list.values():
|
||||
report.result = results.get(report.full_name, None)
|
||||
module_reports.append(report)
|
||||
ret.append((module, module_reports))
|
||||
@@ -607,7 +614,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
|
||||
|
||||
# Retrieve the Report and attach the JobResult to it
|
||||
module, report_name = result.name.split('.')
|
||||
module, report_name = result.name.split('.', maxsplit=1)
|
||||
report = get_report(module, report_name)
|
||||
report.result = result
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
||||
queryset=ContentType.objects.filter(
|
||||
model__in=VLANGROUP_SCOPE_TYPES
|
||||
),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
default=None
|
||||
)
|
||||
|
||||
@@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet):
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filtersets.IPAddressFilterSet
|
||||
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def update(self, request, *args, **kwargs):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class FHRPGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
|
||||
|
||||
@@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
|
||||
@@ -88,6 +88,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm):
|
||||
class RIRForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('RIR', (
|
||||
'name', 'slug', 'is_private', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = [
|
||||
@@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
|
||||
class RoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Role', (
|
||||
'name', 'slug', 'weight', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = [
|
||||
@@ -417,7 +429,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
|
||||
if cluster := nat_inside_parent.virtual_machine.cluster:
|
||||
initial['nat_cluster'] = cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
@@ -537,9 +550,15 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
fields = (
|
||||
'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
|
||||
)
|
||||
widgets = {
|
||||
'protocol': StaticSelect(),
|
||||
'auth_type': StaticSelect(),
|
||||
'ip_status': StaticSelect(),
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object()
|
||||
|
||||
# Check if we need to create a new IPAddress for the group
|
||||
if self.cleaned_data.get('ip_address'):
|
||||
@@ -553,7 +572,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
if not IPAddress.objects.filter(pk=ipaddress.pk).first():
|
||||
if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first():
|
||||
raise PermissionsViolation()
|
||||
|
||||
return instance
|
||||
@@ -784,6 +803,12 @@ class ServiceTemplateForm(NetBoxModelForm):
|
||||
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Service Template', (
|
||||
'name', 'protocol', 'ports', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ('name', 'protocol', 'ports', 'description', 'tags')
|
||||
@@ -854,6 +879,7 @@ class ServiceCreateForm(ServiceForm):
|
||||
del self.fields[field].widget.attrs['required']
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.cleaned_data['service_template']:
|
||||
# Create a new Service from the specified template
|
||||
service_template = self.cleaned_data['service_template']
|
||||
|
||||
@@ -8,8 +8,6 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Device
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.fields import IPNetworkField, IPAddressField
|
||||
@@ -17,8 +15,7 @@ from ipam.managers import IPAddressManager
|
||||
from ipam.querysets import PrefixQuerySet
|
||||
from ipam.validators import DNSValidator
|
||||
from netbox.config import get_config
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
|
||||
__all__ = (
|
||||
'Aggregate',
|
||||
@@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
|
||||
)
|
||||
})
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk:
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr, None) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
|
||||
verbose_name='IP addresses'
|
||||
)
|
||||
|
||||
clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
|
||||
|
||||
class Meta:
|
||||
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
||||
|
||||
|
||||
@@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
|
||||
|
||||
# Find all relevant VLANGroups
|
||||
q = Q()
|
||||
if vm.cluster.site:
|
||||
if vm.cluster.site.region:
|
||||
site = vm.site or vm.cluster.site
|
||||
if vm.cluster:
|
||||
# Add VLANGroups scoped to the assigned cluster (or its group)
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
|
||||
scope_id=vm.cluster_id
|
||||
)
|
||||
if vm.cluster.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||
scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True)
|
||||
)
|
||||
if vm.cluster.site.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
|
||||
scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True)
|
||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
||||
scope_id=vm.cluster.group_id
|
||||
)
|
||||
if site:
|
||||
# Add VLANGroups scoped to the assigned site (or its group or region)
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||
scope_id=vm.cluster.site_id
|
||||
scope_id=site.pk
|
||||
)
|
||||
if vm.cluster.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
|
||||
scope_id=vm.cluster.group_id
|
||||
)
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
|
||||
scope_id=vm.cluster_id
|
||||
)
|
||||
if site.region:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||
scope_id__in=site.region.get_ancestors(include_self=True)
|
||||
)
|
||||
if site.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
|
||||
scope_id__in=site.group.get_ancestors(include_self=True)
|
||||
)
|
||||
vlan_groups = VLANGroup.objects.filter(q)
|
||||
|
||||
# Return all applicable VLANs
|
||||
@@ -113,7 +117,7 @@ class VLANQuerySet(RestrictedQuerySet):
|
||||
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
|
||||
Q(group__isnull=True, site__isnull=True) # Global VLANs
|
||||
)
|
||||
if vm.cluster.site:
|
||||
q |= Q(site=vm.cluster.site)
|
||||
if site:
|
||||
q |= Q(site=site)
|
||||
|
||||
return self.filter(q)
|
||||
|
||||
@@ -21,6 +21,14 @@ __all__ = (
|
||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_LINK_WITH_DEPTH = """
|
||||
{% load helpers %}
|
||||
{% if record.depth %}
|
||||
<div class="record-depth">
|
||||
@@ -29,8 +37,7 @@ PREFIX_LINK = """
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
|
||||
"""
|
||||
""" + PREFIX_LINK
|
||||
|
||||
IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
@@ -216,14 +223,15 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
|
||||
|
||||
class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
prefix = columns.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
template_code=PREFIX_LINK_WITH_DEPTH,
|
||||
export_raw=True,
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
prefix_flat = tables.Column(
|
||||
prefix_flat = columns.TemplateColumn(
|
||||
accessor=Accessor('prefix'),
|
||||
linkify=True,
|
||||
verbose_name='Prefix (Flat)',
|
||||
template_code=PREFIX_LINK,
|
||||
export_raw=True,
|
||||
verbose_name='Prefix (Flat)'
|
||||
)
|
||||
depth = tables.Column(
|
||||
accessor=Accessor('_depth'),
|
||||
@@ -367,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
assigned = columns.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
linkify=True,
|
||||
linkify=lambda record: record.assigned_object.get_absolute_url(),
|
||||
verbose_name='Assigned'
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -930,6 +930,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
|
||||
|
||||
return return_url
|
||||
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
# Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that
|
||||
# we can evaluate permissions during the creation of a new IPAddress within the form.
|
||||
obj._user = request.user
|
||||
return obj
|
||||
|
||||
|
||||
class FHRPGroupDeleteView(generic.ObjectDeleteView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import threading
|
||||
|
||||
thread_locals = threading.local()
|
||||
|
||||
@@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
||||
if token.is_expired:
|
||||
raise exceptions.AuthenticationFailed("Token expired")
|
||||
|
||||
if not token.user.is_active:
|
||||
raise exceptions.AuthenticationFailed("User inactive")
|
||||
|
||||
user = token.user
|
||||
# When LDAP authentication is active try to load user data from LDAP directory
|
||||
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
|
||||
from netbox.authentication import LDAPBackend
|
||||
ldap_backend = LDAPBackend()
|
||||
|
||||
# Load from LDAP if FIND_GROUP_PERMS is active
|
||||
if ldap_backend.settings.FIND_GROUP_PERMS:
|
||||
user = ldap_backend.populate_user(token.user.username)
|
||||
# Always query LDAP when user is not active, otherwise it is never activated again
|
||||
if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
|
||||
ldap_user = ldap_backend.populate_user(token.user.username)
|
||||
# If the user is found in the LDAP directory use it, if not fallback to the local user
|
||||
if user:
|
||||
return user, token
|
||||
if ldap_user:
|
||||
user = ldap_user
|
||||
|
||||
return token.user, token
|
||||
if not user.is_active:
|
||||
raise exceptions.AuthenticationFailed("User inactive")
|
||||
|
||||
return user, token
|
||||
|
||||
|
||||
class TokenPermissions(DjangoObjectPermissions):
|
||||
|
||||
@@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
# Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
if 'export' in request.GET:
|
||||
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'])
|
||||
|
||||
@@ -108,6 +108,5 @@ class ObjectValidationMixin:
|
||||
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
|
||||
if conforming_count != len(instance):
|
||||
raise ObjectDoesNotExist
|
||||
else:
|
||||
# Check that the instance is matched by the view's queryset
|
||||
self.queryset.get(pk=instance.pk)
|
||||
elif not self.queryset.filter(pk=instance.pk).exists():
|
||||
raise ObjectDoesNotExist
|
||||
|
||||
@@ -24,6 +24,7 @@ AUTH_BACKEND_ATTRS = {
|
||||
'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'),
|
||||
'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'),
|
||||
'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
|
||||
'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
|
||||
'bitbucket': ('BitBucket', 'bitbucket'),
|
||||
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
|
||||
'digitalocean': ('DigitalOcean', 'digital-ocean'),
|
||||
@@ -351,6 +352,14 @@ class LDAPBackend:
|
||||
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
# Optionally set CA cert directory
|
||||
if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
|
||||
|
||||
# Optionally set CA cert file
|
||||
if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
|
||||
10
netbox/netbox/context.py
Normal file
10
netbox/netbox/context.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
__all__ = (
|
||||
'current_request',
|
||||
'webhooks_queue',
|
||||
)
|
||||
|
||||
|
||||
current_request = ContextVar('current_request', default=None)
|
||||
webhooks_queue = ContextVar('webhooks_queue')
|
||||
@@ -80,6 +80,13 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
|
||||
# however FilterSet Factory is setup before this which creates the
|
||||
# initial filters. This recreates the filters so Empty is picked up correctly.
|
||||
self.base_filters = self.__class__.get_filters()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _get_filter_lookup_dict(existing_filter):
|
||||
# Choose the lookup expression map based on the filter type
|
||||
|
||||
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
|
||||
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
|
||||
from extras.forms.customfields import CustomFieldsMixin
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.forms import BootstrapMixin, CSVModelForm
|
||||
@@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
|
||||
"""
|
||||
tags = None # Temporary fix in lieu of tag import support (see #9158)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type).filter(
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
@@ -125,10 +130,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
|
||||
)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type).exclude(
|
||||
return super()._get_custom_fields(content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False)
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)
|
||||
|
||||
@@ -2,7 +2,6 @@ from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from extras.utils import is_taggable
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from netbox.models.features import *
|
||||
@@ -21,7 +20,6 @@ class NetBoxFeatureSet(
|
||||
CustomLinksMixin,
|
||||
CustomValidationMixin,
|
||||
ExportTemplatesMixin,
|
||||
JournalingMixin,
|
||||
TagsMixin,
|
||||
WebhooksMixin
|
||||
):
|
||||
@@ -32,7 +30,7 @@ class NetBoxFeatureSet(
|
||||
def get_prerequisite_models(cls):
|
||||
"""
|
||||
Return a list of model types that are required to create this model or empty list if none. This is used for
|
||||
showing prequisite warnings in the UI on the list and detail views.
|
||||
showing prerequisite warnings in the UI on the list and detail views.
|
||||
"""
|
||||
return []
|
||||
|
||||
@@ -52,7 +50,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
|
||||
abstract = True
|
||||
|
||||
|
||||
class NetBoxModel(NetBoxFeatureSet, models.Model):
|
||||
class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model):
|
||||
"""
|
||||
Primary models represent real objects within the infrastructure being modeled.
|
||||
"""
|
||||
@@ -61,25 +59,6 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def clone(self):
|
||||
"""
|
||||
Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
|
||||
populating an object creation form in the UI.
|
||||
"""
|
||||
attrs = {}
|
||||
|
||||
for field_name in getattr(self, 'clone_fields', []):
|
||||
field = self._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(self)
|
||||
if field_value not in (None, ''):
|
||||
attrs[field_name] = field_value
|
||||
|
||||
# Include tags (if applicable)
|
||||
if is_taggable(self):
|
||||
attrs['tags'] = [tag.pk for tag in self.tags.all()]
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
|
||||
"""
|
||||
|
||||
@@ -10,12 +10,13 @@ from django.db import models
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||
from extras.utils import register_features
|
||||
from extras.utils import is_taggable, register_features
|
||||
from netbox.signals import post_clean
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggingMixin',
|
||||
'CloningMixin',
|
||||
'CustomFieldsMixin',
|
||||
'CustomLinksMixin',
|
||||
'CustomValidationMixin',
|
||||
@@ -82,6 +83,33 @@ class ChangeLoggingMixin(models.Model):
|
||||
return objectchange
|
||||
|
||||
|
||||
class CloningMixin(models.Model):
|
||||
"""
|
||||
Provides the clone() method used to prepare a copy of existing objects.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def clone(self):
|
||||
"""
|
||||
Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
|
||||
populating an object creation form in the UI.
|
||||
"""
|
||||
attrs = {}
|
||||
|
||||
for field_name in getattr(self, 'clone_fields', []):
|
||||
field = self._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(self)
|
||||
if field_value not in (None, ''):
|
||||
attrs[field_name] = field_value
|
||||
|
||||
# Include tags (if applicable)
|
||||
if is_taggable(self):
|
||||
attrs['tags'] = [tag.pk for tag in self.tags.all()]
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class CustomFieldsMixin(models.Model):
|
||||
"""
|
||||
Enables support for custom fields.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from netbox import thread_locals
|
||||
|
||||
|
||||
def set_request(request):
|
||||
thread_locals.request = request
|
||||
|
||||
|
||||
def get_request():
|
||||
return getattr(thread_locals, 'request', None)
|
||||
@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.3.2'
|
||||
VERSION = '3.3.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -81,6 +81,7 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []
|
||||
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
@@ -404,6 +405,7 @@ STATIC_URL = f'/{BASE_PATH}static/'
|
||||
STATICFILES_DIRS = (
|
||||
os.path.join(BASE_DIR, 'project-static', 'dist'),
|
||||
os.path.join(BASE_DIR, 'project-static', 'img'),
|
||||
os.path.join(BASE_DIR, 'project-static', 'js'),
|
||||
('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs
|
||||
)
|
||||
|
||||
@@ -443,6 +445,10 @@ EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
'json': 'utilities.serializers.json',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Sentry
|
||||
@@ -498,7 +504,7 @@ for param in dir(configuration):
|
||||
|
||||
# Force usage of PostgreSQL's JSONB field for extra data
|
||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||
|
||||
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
|
||||
|
||||
#
|
||||
# Django Prometheus
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -210,7 +211,7 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={request.path}' if request else ''
|
||||
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
@@ -424,6 +425,12 @@ class CustomFieldColumn(tables.Column):
|
||||
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customfield.label or customfield.name
|
||||
# We can't logically sort on FK values
|
||||
if customfield.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
kwargs['orderable'] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import platform
|
||||
import sys
|
||||
from collections import namedtuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
@@ -8,6 +9,7 @@ from django.shortcuts import redirect, render
|
||||
from django.template import loader
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
@@ -24,100 +26,90 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||
from netbox.constants import SEARCH_MAX_RESULTS
|
||||
from netbox.forms import SearchForm
|
||||
from netbox.search import SEARCH_TYPES
|
||||
from tenancy.models import Tenant
|
||||
from tenancy.models import Contact, Tenant
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
|
||||
|
||||
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
|
||||
|
||||
|
||||
class HomeView(View):
|
||||
template_name = 'home.html'
|
||||
|
||||
def get(self, request):
|
||||
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
|
||||
return redirect("login")
|
||||
return redirect('login')
|
||||
|
||||
connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
_path__is_complete=True
|
||||
)
|
||||
connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
).count
|
||||
power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
_path__is_complete=True
|
||||
)
|
||||
connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
).count
|
||||
interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
|
||||
_path__is_complete=True
|
||||
)
|
||||
).count
|
||||
|
||||
def get_count_queryset(model):
|
||||
return model.objects.restrict(request.user, 'view').count
|
||||
|
||||
def build_stats():
|
||||
org = (
|
||||
("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
|
||||
("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count),
|
||||
Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
|
||||
Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
|
||||
Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
|
||||
)
|
||||
dcim = (
|
||||
("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count),
|
||||
("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count),
|
||||
("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count),
|
||||
Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
|
||||
Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
|
||||
Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
|
||||
)
|
||||
ipam = (
|
||||
("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count),
|
||||
("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count),
|
||||
("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count),
|
||||
("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count),
|
||||
("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count),
|
||||
("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
|
||||
|
||||
Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
|
||||
Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
|
||||
Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
|
||||
Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
|
||||
Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
|
||||
Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
|
||||
)
|
||||
circuits = (
|
||||
("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count),
|
||||
("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count),
|
||||
Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
|
||||
Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
|
||||
)
|
||||
virtualization = (
|
||||
("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count),
|
||||
("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count),
|
||||
|
||||
Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
|
||||
get_count_queryset(Cluster)),
|
||||
Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
|
||||
get_count_queryset(VirtualMachine)),
|
||||
)
|
||||
connections = (
|
||||
("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count),
|
||||
("dcim.view_consoleport", "Console", connected_consoleports.count),
|
||||
("dcim.view_interface", "Interfaces", connected_interfaces.count),
|
||||
("dcim.view_powerport", "Power Connections", connected_powerports.count),
|
||||
Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
|
||||
Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
|
||||
Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
|
||||
Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
|
||||
)
|
||||
power = (
|
||||
("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
|
||||
("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
|
||||
Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
|
||||
Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
|
||||
)
|
||||
wireless = (
|
||||
("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
|
||||
("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
|
||||
Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
|
||||
get_count_queryset(WirelessLAN)),
|
||||
Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
|
||||
get_count_queryset(WirelessLink)),
|
||||
)
|
||||
sections = (
|
||||
("Organization", org, "domain"),
|
||||
("IPAM", ipam, "counter"),
|
||||
("Virtualization", virtualization, "monitor"),
|
||||
("Inventory", dcim, "server"),
|
||||
("Circuits", circuits, "transit-connection-variant"),
|
||||
("Connections", connections, "cable-data"),
|
||||
("Power", power, "flash"),
|
||||
("Wireless", wireless, "wifi"),
|
||||
stats = (
|
||||
(_('Organization'), org, 'domain'),
|
||||
(_('IPAM'), ipam, 'counter'),
|
||||
(_('Virtualization'), virtualization, 'monitor'),
|
||||
(_('Inventory'), dcim, 'server'),
|
||||
(_('Circuits'), circuits, 'transit-connection-variant'),
|
||||
(_('Connections'), connections, 'cable-data'),
|
||||
(_('Power'), power, 'flash'),
|
||||
(_('Wireless'), wireless, 'wifi'),
|
||||
)
|
||||
|
||||
stats = []
|
||||
for section_label, section_items, icon_class in sections:
|
||||
items = []
|
||||
for perm, item_label, get_count in section_items:
|
||||
app, scope = perm.split(".")
|
||||
url = ":".join((app, scope.replace("view_", "") + "_list"))
|
||||
item = {
|
||||
"label": item_label,
|
||||
"count": None,
|
||||
"url": url,
|
||||
"disabled": True,
|
||||
"icon": icon_class,
|
||||
}
|
||||
if request.user.has_perm(perm):
|
||||
item["count"] = get_count()
|
||||
item["disabled"] = False
|
||||
items.append(item)
|
||||
stats.append((section_label, items, icon_class))
|
||||
|
||||
return stats
|
||||
|
||||
# Compile changelog table
|
||||
|
||||
@@ -774,7 +774,6 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
model_form = None
|
||||
filterset = None
|
||||
table = None
|
||||
patterned_fields = ('name', 'label')
|
||||
|
||||
def get_required_permission(self):
|
||||
return f'dcim.add_{self.queryset.model._meta.model_name}'
|
||||
@@ -804,23 +803,25 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
new_components = []
|
||||
data = deepcopy(form.cleaned_data)
|
||||
replication_data = {
|
||||
field: data.pop(field) for field in form.replication_fields
|
||||
}
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
|
||||
for obj in data['pk']:
|
||||
|
||||
pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
|
||||
pattern_count = len(replication_data[form.replication_fields[0]])
|
||||
for i in range(pattern_count):
|
||||
component_data = {
|
||||
self.parent_field: obj.pk
|
||||
}
|
||||
|
||||
for field_name in self.patterned_fields:
|
||||
if data.get(f'{field_name}_pattern'):
|
||||
component_data[field_name] = data[f'{field_name}_pattern'][i]
|
||||
|
||||
component_data.update(data)
|
||||
for field, values in replication_data.items():
|
||||
if values:
|
||||
component_data[field] = values[i]
|
||||
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
instance = component_form.save()
|
||||
@@ -829,7 +830,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
for e in errors:
|
||||
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
|
||||
form.add_error(field, '{}: {}'.format(obj, ', '.join(e)))
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
|
||||
|
||||
@@ -125,9 +125,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
actions = self.get_permitted_actions(request.user, model=self.child_model)
|
||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||
|
||||
table_data = self.prep_table_data(request, child_objects, instance)
|
||||
table = self.get_table(table_data, request, bool(actions))
|
||||
table = self.get_table(table_data, request, has_bulk_actions)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
if is_htmx(request):
|
||||
@@ -173,7 +174,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
|
||||
obj = model_form.save()
|
||||
|
||||
# Enforce object-level permissions
|
||||
if not self.queryset.filter(pk=obj.pk).first():
|
||||
if not self.queryset.filter(pk=obj.pk).exists():
|
||||
raise PermissionsViolation()
|
||||
|
||||
# Iterate through the related object forms (if any), validating and saving each instance.
|
||||
@@ -390,7 +391,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
obj = form.save()
|
||||
|
||||
# Check that the new object conforms with any assigned object-level permissions
|
||||
if not self.queryset.filter(pk=obj.pk).first():
|
||||
if not self.queryset.filter(pk=obj.pk).exists():
|
||||
raise PermissionsViolation()
|
||||
|
||||
msg = '{} {}'.format(
|
||||
@@ -538,10 +539,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
"""
|
||||
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
|
||||
"""
|
||||
template_name = 'dcim/component_create.html'
|
||||
template_name = 'generic/object_edit.html'
|
||||
form = None
|
||||
model_form = None
|
||||
patterned_fields = ('name', 'label')
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'add')
|
||||
@@ -549,44 +549,38 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
def alter_object(self, instance, request):
|
||||
return instance
|
||||
|
||||
def initialize_forms(self, request):
|
||||
def initialize_form(self, request):
|
||||
data = request.POST if request.method == 'POST' else None
|
||||
initial_data = normalize_querydict(request.GET)
|
||||
|
||||
form = self.form(data=data, initial=request.GET)
|
||||
model_form = self.model_form(data=data, initial=initial_data)
|
||||
form = self.form(data=data, initial=initial_data)
|
||||
|
||||
# These fields will be set from the pattern values
|
||||
for field_name in self.patterned_fields:
|
||||
model_form.fields[field_name].widget = HiddenInput()
|
||||
|
||||
return form, model_form
|
||||
return form
|
||||
|
||||
def get(self, request):
|
||||
form, model_form = self.initialize_forms(request)
|
||||
form = self.initialize_form(request)
|
||||
instance = self.alter_object(self.queryset.model(), request)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'object': instance,
|
||||
'replication_form': form,
|
||||
'form': model_form,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
logger = logging.getLogger('netbox.views.ComponentCreateView')
|
||||
form, model_form = self.initialize_forms(request)
|
||||
form = self.initialize_form(request)
|
||||
instance = self.alter_object(self.queryset.model(), request)
|
||||
|
||||
if form.is_valid():
|
||||
new_components = []
|
||||
data = deepcopy(request.POST)
|
||||
pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern'])
|
||||
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
|
||||
|
||||
for i in range(pattern_count):
|
||||
for field_name in self.patterned_fields:
|
||||
if form.cleaned_data.get(f'{field_name}_pattern'):
|
||||
data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i]
|
||||
for field_name in self.form.replication_fields:
|
||||
if form.cleaned_data.get(field_name):
|
||||
data[field_name] = form.cleaned_data[field_name][i]
|
||||
|
||||
if hasattr(form, 'get_iterative_data'):
|
||||
data.update(form.get_iterative_data(i))
|
||||
@@ -626,7 +620,6 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'object': instance,
|
||||
'replication_form': form,
|
||||
'form': model_form,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request),
|
||||
})
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars-experimental": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-unused-vars": "off",
|
||||
"no-inner-declarations": "off",
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
|
||||
2
netbox/project-static/dist/cable_trace.css
vendored
2
netbox/project-static/dist/cable_trace.css
vendored
@@ -1 +1 @@
|
||||
:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
|
||||
:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
|
||||
|
||||
4
netbox/project-static/dist/config.js
vendored
4
netbox/project-static/dist/config.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/config.js.map
vendored
4
netbox/project-static/dist/config.js.map
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user