mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-27 15:47:46 -06:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d195f9c6ea | ||
|
|
de298224f1 | ||
|
|
3fd8e48fac | ||
|
|
ab9de43447 | ||
|
|
51ef4fb920 | ||
|
|
7983c2590e | ||
|
|
d77d45e795 | ||
|
|
a24864bc6d | ||
|
|
c671ac2f28 | ||
|
|
18a813aa39 | ||
|
|
14447befb9 | ||
|
|
06ed7ac8a5 | ||
|
|
72f01b3e89 | ||
|
|
2522056bd1 | ||
|
|
01c894e625 | ||
|
|
4286c1cde2 | ||
|
|
383285fb94 | ||
|
|
e23b246d46 | ||
|
|
a543bd469a | ||
|
|
d03859b27b | ||
|
|
bbb133019d | ||
|
|
285187542d | ||
|
|
4d13f4d252 | ||
|
|
e4a9cad756 | ||
|
|
b93b331d86 | ||
|
|
a46255ddda | ||
|
|
6093debb71 | ||
|
|
6dc560596d | ||
|
|
5cb1a6b790 | ||
|
|
ef460a38ed | ||
|
|
786f0cc7f3 | ||
|
|
ccc9e89e1a | ||
|
|
9e35cefaf2 | ||
|
|
1a00765b72 | ||
|
|
4dd229e73a | ||
|
|
db40119faa | ||
|
|
f65744faee | ||
|
|
1ad6d94dc3 | ||
|
|
b759d694ee | ||
|
|
3cb41bbe3a | ||
|
|
099aff5ebe | ||
|
|
f9ceaad284 | ||
|
|
e67624f042 | ||
|
|
27297c7556 | ||
|
|
685ac5f571 | ||
|
|
0ce2b1b779 | ||
|
|
04796a6ac6 | ||
|
|
a8a4bd7c21 | ||
|
|
a0e5e69283 | ||
|
|
df46198b91 | ||
|
|
b670a1e22c | ||
|
|
9b325f4b86 | ||
|
|
952be24365 | ||
|
|
b57a47475d | ||
|
|
4f05cf55a5 | ||
|
|
5dcf8502af | ||
|
|
7a21541ed6 | ||
|
|
ae4ea3443e | ||
|
|
f5dd7d853a | ||
|
|
a1e42dad10 | ||
|
|
6e4b4a553b | ||
|
|
7a410dfd00 | ||
|
|
6fb980349f | ||
|
|
8e251ac33c | ||
|
|
35bcc2ce9d | ||
|
|
69215c411b | ||
|
|
a08b5793f6 | ||
|
|
252bf03525 | ||
|
|
b9b9bb134f | ||
|
|
68966db23d | ||
|
|
9aa7444bf9 | ||
|
|
b0541be107 | ||
|
|
3d1f668235 | ||
|
|
940c947d3f | ||
|
|
c7dd4206c8 | ||
|
|
79bf12a8fe | ||
|
|
2dfbd72f10 | ||
|
|
487827c776 | ||
|
|
6939bf8aed | ||
|
|
e4cb0c3cc2 | ||
|
|
cf2f39a0a8 | ||
|
|
b7cfb2f7d9 | ||
|
|
39cb9c32d6 | ||
|
|
75b71890a4 | ||
|
|
2ffa6d0188 | ||
|
|
026386db50 | ||
|
|
b5125e512f | ||
|
|
a8a36c0a8f |
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.6.1
|
||||
placeholder: v3.6.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.1
|
||||
placeholder: v3.6.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -31,15 +31,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
- uses: dessant/lock-threads@v4
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v6
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<p>The premier source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,9 @@ django-filter
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
||||
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
|
||||
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||
django-mptt
|
||||
django-mptt==0.14.0
|
||||
|
||||
# Context managers for PostgreSQL advisory locks
|
||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
||||
@@ -120,6 +121,10 @@ psycopg[binary,pool]
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
PyYAML
|
||||
|
||||
# Requests
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
|
||||
@@ -342,8 +342,10 @@
|
||||
"100gbase-x-qsfpdd",
|
||||
"200gbase-x-qsfp56",
|
||||
"200gbase-x-qsfpdd",
|
||||
"400gbase-x-qsfp112",
|
||||
"400gbase-x-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"400gbase-x-osfp-rhs",
|
||||
"400gbase-x-cdfp",
|
||||
"400gbase-x-cfp8",
|
||||
"800gbase-x-qsfpdd",
|
||||
|
||||
@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'height': 3,
|
||||
'title': 'Organization',
|
||||
'config': {
|
||||
'models': [
|
||||
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
|
||||
@@ -80,6 +80,14 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
|
||||
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
|
||||
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
|
||||
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement.
|
||||
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
|
||||
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
|
||||
|
||||
```
|
||||
@@ -107,7 +107,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
<h5 class="card-header">{% trans "Circuit List" %}</h5>
|
||||
|
||||
{# A longer string with a context variable #}
|
||||
{% blocktrans with count=object.circuits.count %}
|
||||
{% blocktrans trimmed with count=object.circuits.count %}
|
||||
There are {count} circuits. Would you like to continue?
|
||||
{% endblocktrans %}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# The Premiere Network Source of Truth
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
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, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
|
||||
|
||||
|
||||
@@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
!!! warning
|
||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
||||
|
||||
## Authenticating with Active Directory
|
||||
|
||||
Integrating Active Directory for authentication can be a bit challenging as it may require handling different login formats. This solution will allow users to log in either using their full User Principal Name (UPN) or their username alone, by filtering the DN according to either the `sAMAccountName` or the `userPrincipalName`. The following configuration options will allow your users to enter their usernames in the format `username` or `username@domain.tld`.
|
||||
|
||||
Just as before, the configuration options are defined in the file ldap_config.py. First, modify the `AUTH_LDAP_USER_SEARCH` option to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
```
|
||||
|
||||
In addition, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to `None` as described in the previous sections. Next, modify `AUTH_LDAP_USER_ATTR_MAP` to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we need to add one more configuration option, `AUTH_LDAP_USER_QUERY_FIELD`. The following should be added to your LDAP configuration file:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
```
|
||||
|
||||
With these configuration options, your users will be able to log in either with or without the UPN suffix.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
!!! info
|
||||
This configuration is intended to serve as a template, but may need to be modified in accordance with your environment.
|
||||
|
||||
```python
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com:3269"
|
||||
|
||||
# The following may be needed if you are binding to Active Directory.
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0
|
||||
}
|
||||
|
||||
# Set the DN and password for the NetBox service account.
|
||||
AUTH_LDAP_BIND_DN = "CN=NETBOXSA,OU=Service Accounts,DC=example,DC=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
|
||||
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
|
||||
# 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 = False
|
||||
|
||||
# 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'
|
||||
|
||||
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
|
||||
# username is not in their DN (Active Directory).
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
|
||||
# If a user's DN is producible from their username, we don't need to search.
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = None
|
||||
|
||||
# You can map user attributes to Django attributes as so.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# hierarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
"dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
|
||||
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Mirror LDAP group assignments.
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
|
||||
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# For more granular permissions, we can map LDAP groups to Django groups.
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache groups for one hour to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
```
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.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>
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.4 (2023-10-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
|
||||
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
|
||||
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
|
||||
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
|
||||
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
|
||||
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
|
||||
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
|
||||
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
|
||||
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
|
||||
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
|
||||
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
|
||||
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
|
||||
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
|
||||
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
|
||||
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
|
||||
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
|
||||
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
|
||||
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
|
||||
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
|
||||
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
|
||||
|
||||
---
|
||||
|
||||
## v3.6.3 (2023-09-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
|
||||
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
|
||||
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
|
||||
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
|
||||
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
|
||||
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
|
||||
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
|
||||
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
|
||||
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
|
||||
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
|
||||
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
|
||||
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
|
||||
|
||||
---
|
||||
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
|
||||
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
|
||||
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
|
||||
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
|
||||
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
|
||||
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
|
||||
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
|
||||
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
|
||||
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
|
||||
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
|
||||
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
|
||||
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
|
||||
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
|
||||
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
|
||||
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
|
||||
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
|
||||
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
|
||||
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
|
||||
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
|
||||
|
||||
---
|
||||
|
||||
## v3.6.1 (2023-09-06)
|
||||
|
||||
### Enhancements
|
||||
@@ -23,7 +108,7 @@
|
||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
||||
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
|
||||
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modying the configuration when maintenance mode is enabled
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ class DataSourceTypeChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOCAL, _('Local'), 'gray'),
|
||||
(GIT, _('Git'), 'blue'),
|
||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
||||
(GIT, 'Git', 'blue'),
|
||||
(AMAZON_S3, 'Amazon S3', 'blue'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ class GitBackend(DataBackend):
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
|
||||
@@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(NetBoxModelViewSet):
|
||||
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
@@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
||||
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
SiteGroup.objects.all(),
|
||||
Site,
|
||||
@@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationViewSet(NetBoxModelViewSet):
|
||||
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
@@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||
@@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
|
||||
@@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
@@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
|
||||
@@ -1745,6 +1745,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
unterminated = django_filters.BooleanFilter(
|
||||
method='_unterminated',
|
||||
label=_('Unterminated'),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@@ -1812,6 +1816,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
def _unterminated(self, queryset, name, value):
|
||||
if value:
|
||||
terminated_ids = (
|
||||
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
|
||||
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
|
||||
.values("id")
|
||||
)
|
||||
return queryset.exclude(id__in=terminated_ids)
|
||||
else:
|
||||
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
|
||||
terminations__cable_end=CableEndChoices.SIDE_B
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
help_texts = {
|
||||
'time_zone': mark_safe(
|
||||
_('Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)')
|
||||
'{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
|
||||
_('Time zone'), _('available options')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
model = DeviceRole
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -547,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
}
|
||||
if 'location' in data:
|
||||
if location := data.get('location'):
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
f"location__{self.fields['location'].to_field_name}": location,
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
|
||||
help_text=mark_safe(
|
||||
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
|
||||
)
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
model = InventoryItemRole
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side a device'),
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side A device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_a_type = CSVContentTypeField(
|
||||
label=_('Side a type'),
|
||||
label=_('Side A type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side A type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
label=_('Side a name'),
|
||||
help_text=_('Side A component name')
|
||||
label=_('Side A name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Termination B
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side b device'),
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side B device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_b_type = CSVContentTypeField(
|
||||
label=_('Side b type'),
|
||||
label=_('Side B type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side B type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
label=_('Side b name'),
|
||||
help_text=_('Side B component name')
|
||||
label=_('Side B name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Cable attributes
|
||||
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
def _clean_side(self, side):
|
||||
@@ -1188,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
@@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
@@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
unterminated = forms.NullBooleanField(
|
||||
label=_('Unterminated'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('PoE'), ('poe_mode', 'poe_type')),
|
||||
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'position')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_device_counts(apps, schema_editor):
|
||||
Device = apps.get_model("dcim", "Device")
|
||||
devices = Device.objects.annotate(
|
||||
_console_port_count=Count('consoleports', distinct=True),
|
||||
_console_server_port_count=Count('consoleserverports', distinct=True),
|
||||
_power_port_count=Count('powerports', distinct=True),
|
||||
_power_outlet_count=Count('poweroutlets', distinct=True),
|
||||
_interface_count=Count('interfaces', distinct=True),
|
||||
_front_port_count=Count('frontports', distinct=True),
|
||||
_rear_port_count=Count('rearports', distinct=True),
|
||||
_device_bay_count=Count('devicebays', distinct=True),
|
||||
_module_bay_count=Count('modulebays', distinct=True),
|
||||
_inventory_item_count=Count('inventoryitems', distinct=True),
|
||||
)
|
||||
|
||||
for device in devices:
|
||||
device.console_port_count = device._console_port_count
|
||||
device.console_server_port_count = device._console_server_port_count
|
||||
device.power_port_count = device._power_port_count
|
||||
device.power_outlet_count = device._power_outlet_count
|
||||
device.interface_count = device._interface_count
|
||||
device.front_port_count = device._front_port_count
|
||||
device.rear_port_count = device._rear_port_count
|
||||
device.device_bay_count = device._device_bay_count
|
||||
device.module_bay_count = device._module_bay_count
|
||||
device.inventory_item_count = device._inventory_item_count
|
||||
|
||||
Device.objects.bulk_update(devices, [
|
||||
'console_port_count',
|
||||
'console_server_port_count',
|
||||
'power_port_count',
|
||||
'power_outlet_count',
|
||||
'interface_count',
|
||||
'front_port_count',
|
||||
'rear_port_count',
|
||||
'device_bay_count',
|
||||
'module_bay_count',
|
||||
'inventory_item_count',
|
||||
], batch_size=100)
|
||||
update_counts(Device, 'console_port_count', 'consoleports')
|
||||
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||
update_counts(Device, 'power_port_count', 'powerports')
|
||||
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||
update_counts(Device, 'interface_count', 'interfaces')
|
||||
update_counts(Device, 'front_port_count', 'frontports')
|
||||
update_counts(Device, 'rear_port_count', 'rearports')
|
||||
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_devicetype_template_counts(apps, schema_editor):
|
||||
DeviceType = apps.get_model("dcim", "DeviceType")
|
||||
device_types = list(DeviceType.objects.all().annotate(
|
||||
_console_port_template_count=Count('consoleporttemplates', distinct=True),
|
||||
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
|
||||
_power_port_template_count=Count('powerporttemplates', distinct=True),
|
||||
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
|
||||
_interface_template_count=Count('interfacetemplates', distinct=True),
|
||||
_front_port_template_count=Count('frontporttemplates', distinct=True),
|
||||
_rear_port_template_count=Count('rearporttemplates', distinct=True),
|
||||
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
|
||||
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
|
||||
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
|
||||
))
|
||||
|
||||
for devicetype in device_types:
|
||||
devicetype.console_port_template_count = devicetype._console_port_template_count
|
||||
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
|
||||
devicetype.power_port_template_count = devicetype._power_port_template_count
|
||||
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
|
||||
devicetype.interface_template_count = devicetype._interface_template_count
|
||||
devicetype.front_port_template_count = devicetype._front_port_template_count
|
||||
devicetype.rear_port_template_count = devicetype._rear_port_template_count
|
||||
devicetype.device_bay_template_count = devicetype._device_bay_template_count
|
||||
devicetype.module_bay_template_count = devicetype._module_bay_template_count
|
||||
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
|
||||
|
||||
DeviceType.objects.bulk_update(device_types, [
|
||||
'console_port_template_count',
|
||||
'console_server_port_template_count',
|
||||
'power_port_template_count',
|
||||
'power_outlet_template_count',
|
||||
'interface_template_count',
|
||||
'front_port_template_count',
|
||||
'rear_port_template_count',
|
||||
'device_bay_template_count',
|
||||
'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
])
|
||||
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def populate_virtualchassis_members(apps, schema_editor):
|
||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||
|
||||
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
|
||||
|
||||
for vc in vcs:
|
||||
vc.member_count = vc._member_count
|
||||
|
||||
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
|
||||
update_counts(VirtualChassis, 'member_count', 'members')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -20,7 +20,7 @@ from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
@@ -98,10 +98,10 @@ class Cable(PrimaryModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
@@ -518,9 +518,16 @@ class CablePath(models.Model):
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
if len(set(t.link for t in terminations)) > 1 and (
|
||||
position_stack and len(terminations) != len(position_stack[-1])
|
||||
):
|
||||
is_split = True
|
||||
break
|
||||
|
||||
@@ -529,46 +536,68 @@ class CablePath(models.Model):
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
|
||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
||||
link = terminations[0].link
|
||||
if link is None and len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
elif link is None:
|
||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||
links = [termination.link for termination in terminations if termination.link is not None]
|
||||
if len(links) == 0:
|
||||
if len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert type(link) in (Cable, WirelessLink)
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
|
||||
# Step 3: Record the link and update path status if not "connected"
|
||||
path.append([object_to_path_node(link)])
|
||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
if len(not_connected_terminations) > 0:
|
||||
is_complete = False
|
||||
is_split = True
|
||||
|
||||
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
|
||||
cables = []
|
||||
for link in links:
|
||||
if object_to_path_node(link) not in cables:
|
||||
cables.append(object_to_path_node(link))
|
||||
path.append(cables)
|
||||
|
||||
# Step 5: Update the path status if a link is not connected
|
||||
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
|
||||
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
|
||||
is_active = False
|
||||
|
||||
# Step 4: Determine the far-end terminations
|
||||
if isinstance(link, Cable):
|
||||
# Step 6: Determine the far-end terminations
|
||||
if isinstance(links[0], Cable):
|
||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# Terminations must all belong to same end of Cable
|
||||
local_cable_end = local_cable_terminations[0].cable_end
|
||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
||||
remote_cable_terminations = CableTermination.objects.filter(
|
||||
cable=link,
|
||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
||||
)
|
||||
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
|
||||
remote_terminations = [
|
||||
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
|
||||
]
|
||||
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
# Remote Terminations must all be of the same type, otherwise return a split path
|
||||
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Step 7: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
||||
])
|
||||
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
# Step 8: Determine the "next hop" terminations, if applicable
|
||||
if not remote_terminations:
|
||||
break
|
||||
|
||||
@@ -577,20 +606,32 @@ class CablePath(models.Model):
|
||||
rear_ports = RearPort.objects.filter(
|
||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||
)
|
||||
if len(rear_ports) > 1:
|
||||
assert all(rp.positions == 1 for rp in rear_ports)
|
||||
elif rear_ports[0].positions > 1:
|
||||
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||
|
||||
terminations = rear_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], RearPort):
|
||||
|
||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
||||
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||
rear_port_position=1
|
||||
)
|
||||
# Obtain the individual front ports based on the termination and all positions
|
||||
elif len(remote_terminations) > 1 and position_stack:
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
@@ -632,9 +673,16 @@ class CablePath(models.Model):
|
||||
|
||||
terminations = [circuit_termination]
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
is_complete = True
|
||||
# Check for non-symmetric path
|
||||
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = True
|
||||
elif len(remote_terminations) == 0:
|
||||
is_complete = False
|
||||
else:
|
||||
# Unsupported topology, mark as split and exit
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
return cls(
|
||||
@@ -740,3 +788,15 @@ class CablePath(models.Model):
|
||||
return [
|
||||
ct.get_peer_termination() for ct in nodes
|
||||
]
|
||||
|
||||
def get_asymmetric_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
asymmetric_nodes = []
|
||||
for nodes in self.path_objects:
|
||||
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
|
||||
asymmetric_nodes.extend([node for node in nodes if node.link is None])
|
||||
|
||||
return asymmetric_nodes
|
||||
|
||||
@@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
self._original_device_type = self.__dict__.get('device_type_id')
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
@@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
self._original_device = self.__dict__.get('device_id')
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
@@ -799,9 +799,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
if self.bridge and self.bridge.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
raise ValidationError({
|
||||
'bridge': _("""
|
||||
The selected bridge interface ({bridge}) belongs to a different device
|
||||
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
|
||||
'bridge': _(
|
||||
"The selected bridge interface ({bridge}) belongs to a different device ({device})."
|
||||
).format(bridge=self.bridge, device=self.bridge.device)
|
||||
})
|
||||
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
|
||||
raise ValidationError({
|
||||
@@ -889,10 +889,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': _("""
|
||||
The untagged VLAN ({untagged_vlan}) must belong to the same site as the
|
||||
interface's parent device, or it must be global.
|
||||
""").format(untagged_vlan=self.untagged_vlan)
|
||||
'untagged_vlan': _(
|
||||
"The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
|
||||
"device, or it must be global."
|
||||
).format(untagged_vlan=self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -1067,9 +1067,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": _("""
|
||||
The number of positions cannot be less than the number of mapped front ports
|
||||
({frontport_count})""").format(frontport_count=frontport_count)
|
||||
"positions": _(
|
||||
"The number of positions cannot be less than the number of mapped front ports "
|
||||
"({frontport_count})"
|
||||
).format(frontport_count=frontport_count)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import yaml
|
||||
from functools import cached_property
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@@ -205,11 +206,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
self._original_u_height = self.__dict__.get('u_height')
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
self._original_front_image = self.__dict__.get('front_image')
|
||||
self._original_rear_image = self.__dict__.get('rear_image')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
ret = super().save(*args, **kwargs)
|
||||
|
||||
# Delete any previously uploaded image files that are no longer in use
|
||||
if self.front_image != self._original_front_image:
|
||||
self._original_front_image.delete(save=False)
|
||||
if self.rear_image != self._original_rear_image:
|
||||
self._original_rear_image.delete(save=False)
|
||||
if self._original_front_image and self.front_image != self._original_front_image:
|
||||
default_storage.delete(self._original_front_image)
|
||||
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||
default_storage.delete(self._original_rear_image)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role.config_template:
|
||||
if self.role and self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
||||
@@ -174,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
powerpanel=self.power_panel,
|
||||
powerpanel_site=self.power_panel.site
|
||||
))
|
||||
|
||||
# AC voltage cannot be negative
|
||||
|
||||
@@ -32,11 +32,18 @@ class Node(Hyperlink):
|
||||
color: Box fill color (RRGGBB format)
|
||||
labels: An iterable of text strings. Each label will render on a new line within the box.
|
||||
radius: Box corner radius, for rounded corners (default: 10)
|
||||
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
|
||||
which terminations.
|
||||
"""
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, **extra):
|
||||
object = None
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
|
||||
super(Node, self).__init__(href=url, target='_parent', **extra)
|
||||
|
||||
# Save object for reference by cable systems
|
||||
self.object = object
|
||||
|
||||
x, y = position
|
||||
|
||||
# Add the box
|
||||
@@ -77,7 +84,7 @@ class Connector(Group):
|
||||
labels: Iterable of text labels
|
||||
"""
|
||||
|
||||
def __init__(self, start, url, color, labels=[], **extra):
|
||||
def __init__(self, start, url, color, labels=[], description=[], **extra):
|
||||
super().__init__(class_='connector', **extra)
|
||||
|
||||
self.start = start
|
||||
@@ -104,6 +111,8 @@ class Connector(Group):
|
||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
if len(description) > 0:
|
||||
link.set_desc("\n".join(description))
|
||||
|
||||
self.add(link)
|
||||
|
||||
@@ -151,6 +160,8 @@ class CableTraceSVG:
|
||||
elif instance._meta.model_name == 'circuit':
|
||||
labels[0] = f'Circuit {instance}'
|
||||
labels.append(instance.provider)
|
||||
if instance.description:
|
||||
labels.append(instance.description)
|
||||
elif instance._meta.model_name == 'circuittermination':
|
||||
if instance.xconnect_id:
|
||||
labels.append(f'{instance.xconnect_id}')
|
||||
@@ -206,7 +217,8 @@ class CableTraceSVG:
|
||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||
color=self._get_color(term),
|
||||
labels=self._get_labels(term),
|
||||
radius=5
|
||||
radius=5,
|
||||
object=term
|
||||
)
|
||||
nodes_height = max(nodes_height, node.box['height'])
|
||||
nodes.append(node)
|
||||
@@ -238,22 +250,65 @@ class CableTraceSVG:
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_cable(self, cable):
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
def draw_cable(self, cable, terminations, cable_count=0):
|
||||
"""
|
||||
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||
|
||||
:param cable: The cable to draw
|
||||
:param terminations: List of terminations to build positioning data off of
|
||||
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||
tooltip.
|
||||
"""
|
||||
|
||||
# If the cable count is higher than 2, collapse the description into a tooltip
|
||||
if cable_count > 2:
|
||||
# Use the cable __str__ function to denote the cable
|
||||
labels = [f'{cable}']
|
||||
|
||||
# Include the label and the status description in the tooltip
|
||||
description = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
|
||||
# If there is only one termination, center on that termination
|
||||
# Otherwise average the center across the terminations
|
||||
if len(terminations) == 1:
|
||||
center = terminations[0].bottom_center[0]
|
||||
else:
|
||||
# Get a list of termination centers
|
||||
termination_centers = [term.bottom_center[0] for term in terminations]
|
||||
# Average the centers
|
||||
center = sum(termination_centers) / len(termination_centers)
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=(self.center + OFFSET, self.cursor),
|
||||
start=(center, self.cursor),
|
||||
color=cable.color or '000000',
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
labels=labels
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Set the cursor position
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
@@ -334,34 +389,52 @@ class CableTraceSVG:
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link = links[0] # Remove Cable from list
|
||||
link_cables = {}
|
||||
fanin = False
|
||||
fanout = False
|
||||
|
||||
# Cable
|
||||
if type(link) is Cable:
|
||||
# Determine if we have fanins or fanouts
|
||||
if len(near_ends) > len(set(links)):
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
fanin = True
|
||||
if len(far_ends) > len(set(links)):
|
||||
fanout = True
|
||||
cursor = self.cursor
|
||||
for link in links:
|
||||
# Cable
|
||||
if type(link) is Cable and not link_cables.get(link.pk):
|
||||
# Reset cursor
|
||||
self.cursor = cursor
|
||||
# Generate a list of terminations connected to this cable
|
||||
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
|
||||
# Draw the cable
|
||||
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
|
||||
# Add cable to the list of cables
|
||||
link_cables.update({link.pk: cable})
|
||||
# Add cable to drawing
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Account for fan-ins height
|
||||
if len(near_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1 and fanin:
|
||||
for term in terminations:
|
||||
if term.object.cable == link:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
cable = self.draw_cable(link)
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1:
|
||||
for term in terminations:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
|
||||
# Far end termination(s)
|
||||
if len(far_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
self.draw_fanout(term, cable)
|
||||
if fanout:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
|
||||
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
|
||||
else:
|
||||
self.draw_terminations(far_ends)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
|
||||
@@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.enabled:
|
||||
return "enabled"
|
||||
return 'enabled'
|
||||
else:
|
||||
return "disabled"
|
||||
return 'disabled'
|
||||
|
||||
|
||||
def get_interface_connected_attribute(record):
|
||||
"""
|
||||
Get interface disconnected state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.mark_connected or record.cable:
|
||||
return 'connected'
|
||||
else:
|
||||
return 'disconnected'
|
||||
|
||||
|
||||
#
|
||||
@@ -674,6 +684,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
'data-connected': get_interface_connected_attribute
|
||||
}
|
||||
|
||||
|
||||
@@ -871,8 +882,9 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
verbose_name=_('Module Status'),
|
||||
template_code=MODULEBAY_STATUS
|
||||
accessor=tables.A('installed_module__status'),
|
||||
template_code=MODULEBAY_STATUS,
|
||||
verbose_name=_('Module Status')
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
|
||||
@@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
|
||||
1XX: Test direct connections between different endpoint types
|
||||
2XX: Test different cable topologies
|
||||
3XX: Test responses to changes in existing objects
|
||||
4XX: Test to exclude specific cable topologies
|
||||
"""
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
||||
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
|
||||
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
def _get_cablepath(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists.
|
||||
Return a given cable path
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
|
||||
|
||||
:return: The matching CablePath (if any)
|
||||
"""
|
||||
@@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
|
||||
path.append([object_to_path_node(node) for node in step])
|
||||
else:
|
||||
path.append([object_to_path_node(step)])
|
||||
return CablePath.objects.filter(path=path, **kwargs).first()
|
||||
|
||||
cablepath = CablePath.objects.filter(path=path, **kwargs).first()
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
|
||||
first matching CablePath, if found.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNotNone(cablepath, msg='CablePath not found')
|
||||
|
||||
return cablepath
|
||||
|
||||
def assertPathDoesNotExist(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a specific CablePath does *not* exist.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
|
||||
|
||||
def assertPathIsSet(self, origin, cablepath, msg=None):
|
||||
"""
|
||||
Assert that a specific CablePath instance is set as the path on the origin.
|
||||
@@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase):
|
||||
self.assertPathIsSet(interface3, path3)
|
||||
self.assertPathIsSet(interface4, path4)
|
||||
|
||||
def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3]
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3)
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_221_non_symmetric_paths(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
|
||||
rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
frontport5 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
|
||||
)
|
||||
frontport6 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
cable6 = Cable(
|
||||
a_terminations=[frontport4],
|
||||
b_terminations=[frontport5],
|
||||
label='C6'
|
||||
)
|
||||
cable6.save()
|
||||
cable7 = Cable(
|
||||
a_terminations=[rearport5],
|
||||
b_terminations=[rearport6],
|
||||
label='C7'
|
||||
)
|
||||
cable7.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1],
|
||||
label='C1'
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3],
|
||||
label='C5'
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport6],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7),
|
||||
(rearport1, rearport5), (frontport1, frontport5), (cable1, cable6)
|
||||
),
|
||||
is_complete=False,
|
||||
is_split=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
@@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
def test_401_exclude_midspan_devices(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
|
||||
[FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] /
|
||||
"""
|
||||
device = Device.objects.create(
|
||||
site=self.site,
|
||||
device_type=self.device.device_type,
|
||||
device_role=self.device.device_role,
|
||||
name='Test mid-span Device'
|
||||
)
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4)
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport2), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
@@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
|
||||
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
|
||||
|
||||
# Cable for unterminated test
|
||||
Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['Cable 1', 'Cable 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_unterminated(self):
|
||||
params = {'unterminated': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'unterminated': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
|
||||
|
||||
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
@@ -17,7 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -2014,6 +2014,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2030,6 +2031,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,6 +2108,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
||||
if form.is_valid():
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
count = 0
|
||||
cable_ids = set()
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
if obj.cable is None:
|
||||
continue
|
||||
obj.cable.delete()
|
||||
count += 1
|
||||
if obj.cable:
|
||||
cable_ids.add(obj.cable.pk)
|
||||
count += 1
|
||||
for cable in Cable.objects.filter(pk__in=cable_ids):
|
||||
cable.delete()
|
||||
|
||||
messages.success(request, "Disconnected {} {}".format(
|
||||
count, self.queryset.model._meta.verbose_name_plural
|
||||
messages.success(request, _("Disconnected {count} {type}").format(
|
||||
count=count,
|
||||
type=self.queryset.model._meta.verbose_name_plural
|
||||
))
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
@@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
||||
required=False
|
||||
)
|
||||
data_file = NestedDataFileSerializer(
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||
data = [
|
||||
{'id': c[0], 'display': c[1]} for c in page
|
||||
]
|
||||
return self.get_paginated_response(data)
|
||||
else:
|
||||
data = []
|
||||
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
|
||||
(ACTION_UPDATE, _('Update'), 'blue'),
|
||||
(ACTION_DELETE, _('Delete'), 'red'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Dashboard widgets
|
||||
#
|
||||
|
||||
class DashboardWidgetColorChoices(ChoiceSet):
|
||||
BLUE = 'blue'
|
||||
INDIGO = 'indigo'
|
||||
PURPLE = 'purple'
|
||||
PINK = 'pink'
|
||||
RED = 'red'
|
||||
ORANGE = 'orange'
|
||||
YELLOW = 'yellow'
|
||||
GREEN = 'green'
|
||||
TEAL = 'teal'
|
||||
CYAN = 'cyan'
|
||||
GRAY = 'gray'
|
||||
BLACK = 'black'
|
||||
WHITE = 'white'
|
||||
|
||||
CHOICES = (
|
||||
(BLUE, _('Blue')),
|
||||
(INDIGO, _('Indigo')),
|
||||
(PURPLE, _('Purple')),
|
||||
(PINK, _('Pink')),
|
||||
(RED, _('Red')),
|
||||
(ORANGE, _('Orange')),
|
||||
(YELLOW, _('Yellow')),
|
||||
(GREEN, _('Green')),
|
||||
(TEAL, _('Teal')),
|
||||
(CYAN, _('Cyan')),
|
||||
(GRAY, _('Gray')),
|
||||
(BLACK, _('Black')),
|
||||
(WHITE, _('White')),
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import DashboardWidgetColorChoices
|
||||
from netbox.registry import registry
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
__all__ = (
|
||||
'DashboardWidgetAddForm',
|
||||
@@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
|
||||
required=False
|
||||
)
|
||||
color = forms.ChoiceField(
|
||||
choices=add_blank_choice(ButtonColorChoices),
|
||||
choices=add_blank_choice(DashboardWidgetColorChoices),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import BookmarkOrderingChoices
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
@@ -115,6 +116,22 @@ class DashboardWidget:
|
||||
def name(self):
|
||||
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
|
||||
|
||||
@property
|
||||
def fg_color(self):
|
||||
"""
|
||||
Return the appropriate foreground (text) color for the widget's color.
|
||||
"""
|
||||
if self.color in (
|
||||
ButtonColorChoices.CYAN,
|
||||
ButtonColorChoices.GRAY,
|
||||
ButtonColorChoices.GREY,
|
||||
ButtonColorChoices.TEAL,
|
||||
ButtonColorChoices.WHITE,
|
||||
ButtonColorChoices.YELLOW,
|
||||
):
|
||||
return ButtonColorChoices.BLACK
|
||||
return ButtonColorChoices.WHITE
|
||||
|
||||
@property
|
||||
def form_data(self):
|
||||
return {
|
||||
|
||||
@@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
|
||||
model = Tag
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
@@ -75,13 +76,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'type': _(
|
||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below."
|
||||
)
|
||||
),
|
||||
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data
|
||||
# is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
@@ -90,10 +93,10 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
help_text=_(
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma (for example, "choice1,First Choice").'
|
||||
)
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -325,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||
required=False
|
||||
)
|
||||
tenant_groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Tenat groups'),
|
||||
label=_('Tenant groups'),
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
@@ -515,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
|
||||
if is_static:
|
||||
help_text += _(' (defined statically)')
|
||||
elif value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
value = ', '.join(value)
|
||||
self.fields[param.name].initial = value
|
||||
if is_static:
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
@@ -146,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
raise ValidationError(
|
||||
{'data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
def sync_data(self):
|
||||
@@ -202,7 +202,7 @@ class ConfigContextModel(models.Model):
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||
raise ValidationError(
|
||||
{'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
__all__ = (
|
||||
@@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
self._name = self.__dict__.get('name')
|
||||
|
||||
@property
|
||||
def search_type(self):
|
||||
@@ -231,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return self.choice_set.choices
|
||||
return []
|
||||
|
||||
def get_choice_label(self, value):
|
||||
if not hasattr(self, '_choice_map'):
|
||||
self._choice_map = dict(self.choices)
|
||||
return self._choice_map.get(value, value)
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
@@ -498,7 +504,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
field.model = self
|
||||
field.label = str(self)
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
field.help_text = render_markdown(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def get_module_and_report(module_name, report_name):
|
||||
module = ReportModule.objects.get(file_path=f'{module_name}.py')
|
||||
report = module.reports.get(report_name)
|
||||
report = module.reports.get(report_name)()
|
||||
return module, report
|
||||
|
||||
|
||||
@@ -106,8 +106,6 @@ class Report(object):
|
||||
'failure': 0,
|
||||
'log': [],
|
||||
}
|
||||
if not test_methods:
|
||||
raise Exception("A report must contain at least one test method.")
|
||||
self.test_methods = test_methods
|
||||
|
||||
@classproperty
|
||||
@@ -137,6 +135,13 @@ class Report(object):
|
||||
def source(self):
|
||||
return inspect.getsource(self.__class__)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""
|
||||
Indicates whether the report can be run.
|
||||
"""
|
||||
return bool(self.test_methods)
|
||||
|
||||
#
|
||||
# Logging methods
|
||||
#
|
||||
|
||||
@@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
from ipam.models import VLAN
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import APITestCase, TestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
@@ -1176,7 +1177,11 @@ class CustomFieldImportTest(TestCase):
|
||||
)
|
||||
csv_data = '\n'.join(','.join(row) for row in data)
|
||||
|
||||
response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'})
|
||||
response = self.client.post(reverse('dcim:site_import'), {
|
||||
'data': csv_data,
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(Site.objects.count(), 3)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.models import DeviceType, Manufacturer, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
@@ -434,7 +434,8 @@ class ConfigContextTestCase(
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Create three ConfigContexts
|
||||
for i in range(1, 4):
|
||||
@@ -443,7 +444,7 @@ class ConfigContextTestCase(
|
||||
data={'foo': i}
|
||||
)
|
||||
configcontext.save()
|
||||
configcontext.sites.add(site)
|
||||
configcontext.device_types.add(devicetype)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Config Context X',
|
||||
@@ -451,11 +452,12 @@ class ConfigContextTestCase(
|
||||
'description': 'A new config context',
|
||||
'is_active': True,
|
||||
'regions': [],
|
||||
'sites': [site.pk],
|
||||
'sites': [],
|
||||
'roles': [],
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
||||
@@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
def get_report_module(module, request):
|
||||
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
|
||||
|
||||
class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a single Report and its associated Job (if any).
|
||||
@@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
@@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
if not request.user.has_perm('extras.run_report'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
||||
|
||||
@@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
return render(request, 'extras/report/source.html', {
|
||||
@@ -1062,7 +1066,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
@@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
def get_script_module(module, request):
|
||||
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
|
||||
|
||||
class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
@@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
|
||||
@@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
@@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
|
||||
@@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
|
||||
# Normalize request data to a list of objects
|
||||
requested_objects = request.data if isinstance(request.data, list) else [request.data]
|
||||
limit = len(requested_objects)
|
||||
|
||||
# Serialize and validate the request data
|
||||
serializer = self.write_serializer_class(data=requested_objects, many=True, context={
|
||||
@@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
|
||||
available_objects = self.get_available_objects(parent)
|
||||
available_objects = self.get_available_objects(parent, limit)
|
||||
|
||||
# Determine if the requested number of objects is available
|
||||
if not self.check_sufficient_available(serializer.validated_data, available_objects):
|
||||
@@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
# Prepare object data for deserialization
|
||||
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
|
||||
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
@@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
)
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
|
||||
__all__ = (
|
||||
'AggregateBulkEditForm',
|
||||
@@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
)
|
||||
min_vid = forms.IntegerField(
|
||||
min_value=VLAN_VID_MIN,
|
||||
max_value=VLAN_VID_MAX,
|
||||
@@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
scope_type = ContentTypeChoiceField(
|
||||
label=_('Scope type'),
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False
|
||||
)
|
||||
scope_id = forms.IntegerField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$sitegroup',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
label=_('Location'),
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
label=_('Rack'),
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
clustergroup = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster group')
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$clustergroup',
|
||||
}
|
||||
)
|
||||
|
||||
model = VLANGroup
|
||||
fieldsets = (
|
||||
(None, ('site', 'min_vid', 'max_vid', 'description')),
|
||||
(_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
|
||||
)
|
||||
nullable_fields = ('site', 'description')
|
||||
nullable_fields = ('description',)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Assign scope based on scope_type
|
||||
if self.cleaned_data.get('scope_type'):
|
||||
scope_field = self.cleaned_data['scope_type'].model
|
||||
if scope_obj := self.cleaned_data.get(scope_field):
|
||||
self.cleaned_data['scope_id'] = scope_obj.pk
|
||||
self.changed_data.append('scope_id')
|
||||
else:
|
||||
self.cleaned_data.pop('scope_type')
|
||||
self.changed_data.remove('scope_type')
|
||||
|
||||
|
||||
class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
@@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
|
||||
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')),
|
||||
(_('VRF'), ('vrf_id', 'present_in_vrf_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
||||
@@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
dns_name = forms.CharField(
|
||||
required=False,
|
||||
label=_('DNS Name')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
},
|
||||
label=_('VLAN'),
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@@ -351,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
})
|
||||
elif selected_objects:
|
||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
||||
)
|
||||
@@ -728,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
|
||||
class Meta(ServiceForm.Meta):
|
||||
fields = [
|
||||
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
|
||||
'tags',
|
||||
'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original prefix and VRF so we can check if they have changed on post_save
|
||||
self._prefix = self.prefix
|
||||
self._vrf_id = self.vrf_id
|
||||
self._prefix = self.__dict__.get('prefix')
|
||||
self._vrf_id = self.__dict__.get('vrf_id')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.prefix)
|
||||
@@ -554,25 +554,13 @@ class IPRange(PrimaryModel):
|
||||
# Check that start & end IP versions match
|
||||
if self.start_address.version != self.end_address.version:
|
||||
raise ValidationError({
|
||||
'end_address': _(
|
||||
"Ending address version (IPv{end_address_version}) does not match starting address "
|
||||
"(IPv{start_address_version})"
|
||||
).format(
|
||||
end_address_version=self.end_address.version,
|
||||
start_address_version=self.start_address.version
|
||||
)
|
||||
'end_address': _("Starting and ending IP address versions must match")
|
||||
})
|
||||
|
||||
# Check that the start & end IP prefix lengths match
|
||||
if self.start_address.prefixlen != self.end_address.prefixlen:
|
||||
raise ValidationError({
|
||||
'end_address': _(
|
||||
"Ending address mask (/{end_address_prefixlen}) does not match starting address mask "
|
||||
"(/{start_address_prefixlen})"
|
||||
).format(
|
||||
end_address_prefixlen=self.end_address.prefixlen,
|
||||
start_address_prefixlen=self.start_address.prefixlen
|
||||
)
|
||||
'end_address': _("Starting and ending IP address masks must match")
|
||||
})
|
||||
|
||||
# Check that the ending address is greater than the starting address
|
||||
@@ -794,6 +782,13 @@ class IPAddress(PrimaryModel):
|
||||
def __str__(self):
|
||||
return str(self.address)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Denote the original assigned object (if any) for validation in clean()
|
||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:ipaddress', args=[self.pk])
|
||||
|
||||
@@ -855,6 +850,26 @@ class IPAddress(PrimaryModel):
|
||||
)
|
||||
})
|
||||
|
||||
if self._original_assigned_object_id and self._original_assigned_object_type_id:
|
||||
parent = getattr(self.assigned_object, 'parent_object', None)
|
||||
ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
|
||||
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
|
||||
original_parent = getattr(original_assigned_object, 'parent_object', None)
|
||||
|
||||
# can't use is_primary_ip as self.assigned_object might be changed
|
||||
is_primary = False
|
||||
if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk:
|
||||
is_primary = True
|
||||
if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk:
|
||||
is_primary = True
|
||||
|
||||
if is_primary and (parent != original_parent):
|
||||
raise ValidationError({
|
||||
'assigned_object': _(
|
||||
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
||||
)
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
def test_assign_object(self):
|
||||
"""
|
||||
Test the creation of available IP addresses within a parent IP range.
|
||||
"""
|
||||
site = Site.objects.create(name='Site 1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
|
||||
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
role = DeviceRole.objects.create(name='Switch')
|
||||
device1 = Device.objects.create(
|
||||
name='Device 1',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
|
||||
interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset')
|
||||
device2 = Device.objects.create(
|
||||
name='Device 2',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset')
|
||||
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1),
|
||||
IPAddress(address=IPNetwork('192.168.1.4/24')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
ip1 = ip_addresses[0]
|
||||
ip1.assigned_object = interface1
|
||||
device1.primary_ip4 = ip_addresses[0]
|
||||
device1.save()
|
||||
|
||||
ip2 = ip_addresses[1]
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
|
||||
self.add_permissions('ipam.change_ipaddress')
|
||||
|
||||
# assign to same parent
|
||||
data = {
|
||||
'assigned_object_id': interface2.pk
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# assign to same different parent - should error
|
||||
data = {
|
||||
'assigned_object_id': interface3.pk
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FHRPGroup
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import F, Prefetch
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Round
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -11,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.tables import get_table_ordering
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
@@ -606,7 +606,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
if not request.GET.get('q') and not request.GET.get('sort'):
|
||||
if not get_table_ordering(request, self.table):
|
||||
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
|
||||
return queryset
|
||||
|
||||
@@ -952,7 +952,9 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||
if not get_table_ordering(request, self.table):
|
||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||
return queryset
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -46,12 +46,13 @@ class ChoiceField(serializers.Field):
|
||||
return super().validate_empty_values(data)
|
||||
|
||||
def to_representation(self, obj):
|
||||
if obj == '':
|
||||
return None
|
||||
return {
|
||||
'value': obj,
|
||||
'label': self._choices[obj],
|
||||
}
|
||||
if obj != '':
|
||||
# Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously
|
||||
# configured choice has been removed from FIELD_CHOICES).
|
||||
return {
|
||||
'value': obj,
|
||||
'label': self._choices.get(obj, ''),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data == '':
|
||||
|
||||
@@ -3,6 +3,8 @@ import logging
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django_pglocks import advisory_lock
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from rest_framework import mixins as drf_mixins
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
@@ -157,3 +159,22 @@ class NetBoxModelViewSet(
|
||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
class MPTTLockedMixin:
|
||||
"""
|
||||
Puts pglock on objects that derive from MPTTModel for parallel API calling.
|
||||
Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
|
||||
"""
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low'
|
||||
# When adding a new key, pick something arbitrary and unique so that it is easily searchable in
|
||||
# query logs.
|
||||
ADVISORY_LOCK_KEYS = {
|
||||
# Available object locks
|
||||
'available-prefixes': 100100,
|
||||
'available-ips': 100200,
|
||||
'available-vlans': 100300,
|
||||
'available-asns': 100400,
|
||||
|
||||
# MPTT locks
|
||||
'region': 105100,
|
||||
'sitegroup': 105200,
|
||||
'location': 105300,
|
||||
'tenantgroup': 105400,
|
||||
'contactgroup': 105500,
|
||||
'wirelesslangroup': 105600,
|
||||
'inventoryitem': 105700,
|
||||
'inventoryitemtemplate': 105800,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
|
||||
from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
|
||||
|
||||
__all__ = (
|
||||
'NetBoxModelForm',
|
||||
@@ -17,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm):
|
||||
class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm):
|
||||
"""
|
||||
Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
|
||||
|
||||
@@ -26,18 +27,6 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
"""
|
||||
fieldsets = ()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk)
|
||||
|
||||
def _get_content_type(self):
|
||||
return ContentType.objects.get_for_model(self._meta.model)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
|
||||
|
||||
if ct_value and fk_value:
|
||||
klass = getattr(self, field.ct_field).model_class()
|
||||
if not klass.objects.filter(pk=fk_value).exists():
|
||||
try:
|
||||
obj = klass.objects.get(pk=fk_value)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
field.fk_field: f"Related object not found using the provided value: {fk_value}."
|
||||
})
|
||||
|
||||
# update the GFK field value
|
||||
setattr(self, field.name, obj)
|
||||
|
||||
|
||||
#
|
||||
# NetBox internal base models
|
||||
|
||||
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.1'
|
||||
VERSION = '3.6.4'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
|
||||
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
@@ -355,6 +356,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'django.forms',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'graphiql_debug_toolbar',
|
||||
@@ -430,6 +432,9 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
# This allows us to override Django's stock form widget templates
|
||||
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
|
||||
|
||||
# Set up authentication backends
|
||||
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
|
||||
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
|
||||
|
||||
@@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column):
|
||||
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
return self.customfield.get_choice_label(value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
return ', '.join(v for v in value)
|
||||
return ', '.join(self.customfield.get_choice_label(v) for v in value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
return mark_safe(', '.join(
|
||||
self._linkify_item(obj) for obj in self.customfield.deserialize(value)
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.test import override_settings
|
||||
|
||||
from dcim.models import *
|
||||
from users.models import ObjectPermission
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import ModelViewTestCase, create_tags
|
||||
|
||||
|
||||
@@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
|
||||
def _get_csv_data(self, csv_data):
|
||||
return '\n'.join(csv_data)
|
||||
|
||||
def test_invalid_headers(self):
|
||||
"""
|
||||
Test that import form validation fails when an unknown CSV header is present.
|
||||
"""
|
||||
self.add_permissions('dcim.add_region')
|
||||
|
||||
csv_data = [
|
||||
'name,slug,INVALIDHEADER',
|
||||
'Region 1,region-1,abc',
|
||||
'Region 2,region-2,def',
|
||||
'Region 3,region-3,ghi',
|
||||
]
|
||||
data = {
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'data': self._get_csv_data(csv_data),
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
|
||||
# Form validation should fail with invalid header present
|
||||
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
|
||||
self.assertEqual(Region.objects.count(), 0)
|
||||
|
||||
# Correct the CSV header name
|
||||
csv_data[0] = 'name,slug,description'
|
||||
data['data'] = self._get_csv_data(csv_data)
|
||||
|
||||
# Validation should succeed
|
||||
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
|
||||
self.assertEqual(Region.objects.count(), 3)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_valid_tags(self):
|
||||
csv_data = (
|
||||
@@ -30,6 +60,7 @@ class CSVImportTestCase(ModelViewTestCase):
|
||||
data = {
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'data': self._get_csv_data(csv_data),
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
|
||||
# Assign model-level permission
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.fields import GenericRel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
@@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
m2m_fields[name] = model_field
|
||||
elif isinstance(model_field, GenericRel):
|
||||
# Ignore generic relations (these may be used for other purposes in the form)
|
||||
continue
|
||||
else:
|
||||
model_fields[name] = model_field
|
||||
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_fields[name] = None
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
18
netbox/project-static/dist/netbox.js
vendored
18
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
|
||||
import { getElements } from './util';
|
||||
|
||||
export function initClipboard(): void {
|
||||
for (const element of getElements('a.copy-content')) {
|
||||
for (const element of getElements('.copy-content')) {
|
||||
new Clipboard(element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = {
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_add: 'vlangroup',
|
||||
vlangroup_edit: 'vlangroup',
|
||||
vlangroup_bulk_edit: 'vlangroup',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -141,9 +141,10 @@ class TableState {
|
||||
private virtualButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Underlying DOM Table Caption Element.
|
||||
* Instance of ButtonState for the 'show/hide virtual rows' button.
|
||||
*/
|
||||
private caption: Nullable<HTMLTableCaptionElement> = null;
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private disconnectedButton: ButtonState;
|
||||
|
||||
/**
|
||||
* All table rows in table
|
||||
@@ -166,9 +167,10 @@ class TableState {
|
||||
this.table,
|
||||
'button.toggle-virtual',
|
||||
);
|
||||
|
||||
const caption = this.table.querySelector('caption');
|
||||
this.caption = caption;
|
||||
const toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
'button.toggle-disconnected',
|
||||
);
|
||||
|
||||
if (toggleEnabledButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-enabled' button.", table);
|
||||
@@ -182,10 +184,15 @@ class TableState {
|
||||
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
|
||||
}
|
||||
|
||||
if (toggleDisconnectedButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table);
|
||||
}
|
||||
|
||||
// Attach event listeners to the buttons elements.
|
||||
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
|
||||
// Instantiate ButtonState for each button for state management.
|
||||
this.enabledButton = new ButtonState(
|
||||
@@ -200,6 +207,10 @@ class TableState {
|
||||
toggleVirtualButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
|
||||
);
|
||||
this.disconnectedButton = new ButtonState(
|
||||
toggleDisconnectedButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-connected="disconnected"]'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TableStateError) {
|
||||
// This class is useless for tables that don't have toggle buttons.
|
||||
@@ -211,52 +222,6 @@ class TableState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table caption's text.
|
||||
*/
|
||||
private get captionText(): string {
|
||||
if (this.caption !== null) {
|
||||
return this.caption.innerText;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the table caption's text.
|
||||
*/
|
||||
private set captionText(value: string) {
|
||||
if (this.caption !== null) {
|
||||
this.caption.innerText = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the table caption's text based on the state of each toggle button.
|
||||
*/
|
||||
private toggleCaption(): void {
|
||||
const showEnabled = this.enabledButton.buttonState === 'show';
|
||||
const showDisabled = this.disabledButton.buttonState === 'show';
|
||||
const showVirtual = this.virtualButton.buttonState === 'show';
|
||||
|
||||
if (showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled Interfaces';
|
||||
} else if (showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Virtual Interfaces';
|
||||
} else if (showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Virtual Interfaces';
|
||||
} else if (showEnabled && showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
|
||||
} else {
|
||||
this.captionText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When toggle buttons are clicked, reapply visability all rows and
|
||||
* pass the event to all button handlers
|
||||
@@ -272,7 +237,7 @@ class TableState {
|
||||
instance.enabledButton.handleClick(event);
|
||||
instance.disabledButton.handleClick(event);
|
||||
instance.virtualButton.handleClick(event);
|
||||
instance.toggleCaption();
|
||||
instance.disconnectedButton.handleClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,12 @@ table td > .progress {
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
code {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
span.profile-button .dropdown-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Token" %}</h5>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Swap these terminations for circuit {{ circuit }}?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{% trans "A side" %}:</strong>
|
||||
{% if termination_a %}
|
||||
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% trans "None" %}
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
@@ -19,7 +23,7 @@
|
||||
{% if termination_z %}
|
||||
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% trans "None" %}
|
||||
{% endif %}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
{% blocktrans with count=selected_objects|length %}
|
||||
{% blocktrans trimmed with count=selected_objects|length %}
|
||||
Are you sure you want to disconnect these {{ count }} {{ obj_type_plural }}?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% blocktrans with object_type=object|meta:"verbose_name"|bettertitle %}
|
||||
{% blocktrans trimmed with object_type=object|meta:"verbose_name"|bettertitle %}
|
||||
Cable Trace for {{ object_type }} {{ object }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
@@ -23,7 +23,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="trace-end">
|
||||
{% if path.is_split %}
|
||||
{% if path.is_split and path.get_asymmetric_nodes %}
|
||||
<h3 class="text-danger">{% trans "Asymmetric Path" %}!</h3>
|
||||
<p>{% trans "The nodes below have no links and result in an asymmetric path" %}:</p>
|
||||
<ul class="text-start">
|
||||
{% for next_node in path.get_asymmetric_nodes %}
|
||||
<li class="text-muted">{{ next_node|linkify }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elif path.is_split %}
|
||||
<h3 class="text-danger">{% trans "Path split" %}!</h3>
|
||||
<p>{% trans "Select a node below to continue" %}:</p>
|
||||
<ul class="text-start">
|
||||
@@ -51,10 +59,10 @@
|
||||
<th scope="row">{% trans "Total length" %}</th>
|
||||
<td>
|
||||
{% if total_length %}
|
||||
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
|
||||
{{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
|
||||
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
|
||||
{{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
|
||||
{% else %}
|
||||
<span class="text-muted">{% trans "N/A" %}</span>
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
{% for leg in utilization.legs %}
|
||||
<tr>
|
||||
<td style="padding-left: 20px">
|
||||
{% trans "Leg" context "Leg of a power feed" %} {{ leg }}
|
||||
{% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
|
||||
</td>
|
||||
<td>{{ leg.outlet_count }}</td>
|
||||
<td>{{ leg.allocated }}</td>
|
||||
|
||||
@@ -9,5 +9,6 @@
|
||||
<button type="button" class="dropdown-item toggle-enabled" data-state="show">{% trans "Hide Enabled" %}</button>
|
||||
<button type="button" class="dropdown-item toggle-disabled" data-state="show">{% trans "Hide Disabled" %}</button>
|
||||
<button type="button" class="dropdown-item toggle-virtual" data-state="show">{% trans "Hide Virtual" %}</button>
|
||||
<button type="button" class="dropdown-item toggle-disconnected" data-state="show">{% trans "Hide Disconnected" %}</button>
|
||||
</ul>
|
||||
{% endblock extra_table_controls %}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block table_controls %}
|
||||
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
|
||||
{% endblock table_controls %}
|
||||
|
||||
{% block bulk_delete_controls %}
|
||||
{{ block.super }}
|
||||
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %}{% endblock %}
|
||||
{% block title %}
|
||||
{% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?{% endblocktrans %}</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% blocktrans with device=device_bay.installed_device %}
|
||||
{% blocktrans trimmed with device=device_bay.installed_device %}
|
||||
Remove {{ device }} from {{ device_bay }}?
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
{% blocktrans with device=device_bay.installed_device %}
|
||||
{% blocktrans trimmed with device=device_bay.installed_device %}
|
||||
Are you sure you want to remove <strong>{{ device }}</strong> from <strong>{{ device_bay }}</strong>?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="float-end">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% blocktrans %}Add {{ title }}{% endblocktrans %}
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="float-end">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?module_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% blocktrans %}Add {{ title }}{% endblocktrans %}
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
{% block extra_controls %}
|
||||
{% if perms.dcim.add_device %}
|
||||
<a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
|
||||
<a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-sm btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">{% trans "N/A" %}</td>
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% blocktrans %}Rack {{ object }}{% endblocktrans %}{% endblock %}
|
||||
{% block title %}{% trans "Rack" %} {{ object }}{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% blocktrans %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblocktrans %}{% endblock %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed %}
|
||||
Add New Member to Virtual Chassis {{ virtual_chassis }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% blocktrans with name=vc_form.instance %}
|
||||
{% blocktrans trimmed with name=vc_form.instance %}
|
||||
Editing Virtual Chassis {{ name }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
{% block message %}
|
||||
<p>
|
||||
{% blocktrans with name=device.virtual_chassis %}
|
||||
{% blocktrans trimmed with name=device.virtual_chassis %}
|
||||
Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ name }}?
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
6
netbox/templates/django/forms/widgets/checkbox.html
Normal file
6
netbox/templates/django/forms/widgets/checkbox.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% comment %}
|
||||
Include a hidden field of the same name to ensure that unchecked checkboxes
|
||||
are always included in the submitted form data.
|
||||
{% endcomment %}
|
||||
<input type="hidden" name="{{ widget.name }}" value="">
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
@@ -7,19 +7,20 @@
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans %}
|
||||
<strong>Missing required packages.</strong> This installation of NetBox might be missing one or more required
|
||||
Python packages. These packages are listed in <code>requirements.txt</code> and
|
||||
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
|
||||
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
|
||||
required packages.
|
||||
<strong>{% trans "Missing required packages" %}.</strong>
|
||||
{% blocktrans trimmed %}
|
||||
This installation of NetBox might be missing one or more required Python packages. These packages are listed in
|
||||
<code>requirements.txt</code> and <code>local_requirements.txt</code>, and are normally installed as part of the
|
||||
installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the console and
|
||||
compare the output to the list of required packages.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans %}
|
||||
<strong>WSGI service not restarted after upgrade.</strong> If this installation has recently been upgraded, check
|
||||
that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is running.
|
||||
<strong>{% trans "WSGI service not restarted after upgrade" %}.</strong>
|
||||
{% blocktrans trimmed %}
|
||||
If this installation has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been
|
||||
restarted. This ensures that the new code is running.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans with media_root=settings.MEDIA_ROOT %}
|
||||
<strong>Insufficient write permission to the media root.</strong> The configured media root is
|
||||
<code>{{ media_root }}</code>. Ensure that the user NetBox runs as has access to write files to all locations
|
||||
within this path.
|
||||
<strong>{% trans "Insufficient write permission to the media root" %}.</strong>
|
||||
{% blocktrans trimmed with media_root=settings.MEDIA_ROOT %}
|
||||
The configured media root is <code>{{ media_root }}</code>. Ensure that the user NetBox runs as has access to
|
||||
write files to all locations within this path.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans %}
|
||||
<strong>Database migrations missing.</strong> When upgrading to a new NetBox release, the upgrade script must be
|
||||
run to apply any new database migrations. You can run migrations manually by executing
|
||||
<code>python3 manage.py migrate</code> from the command line.
|
||||
<strong>{% trans "Database migrations missing" %}.</strong>
|
||||
{% blocktrans trimmed %}
|
||||
When upgrading to a new NetBox release, the upgrade script must be run to apply any new database migrations. You
|
||||
can run migrations manually by executing <code>python3 manage.py migrate</code> from the command line.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
<i class="mdi mdi-alert"></i>
|
||||
{% blocktrans %}
|
||||
<strong>Unsupported PostgreSQL version.</strong> Ensure that PostgreSQL version 12 or later is in use. You can
|
||||
check this by connecting to the database using NetBox's credentials and issuing a query for
|
||||
<code>SELECT VERSION()</code>.
|
||||
<strong>{% trans "Unsupported PostgreSQL version" %}.</strong>
|
||||
{% blocktrans trimmed %}
|
||||
Ensure that PostgreSQL version 12 or later is in use. You can check this by connecting to the database using
|
||||
NetBox's credentials and issuing a query for <code>SELECT VERSION()</code>.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock message %}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<td>{{ object.group_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"></th>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user