mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-23 07:56:44 -06:00
Merge branch 'feature' into list_all_objects_to_be_deleted
This commit is contained in:
commit
8a0f487393
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.1
|
placeholder: v3.6.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.1
|
placeholder: v3.6.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -31,15 +31,15 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ jobs:
|
|||||||
run: npm install -g yarn
|
run: npm install -g yarn
|
||||||
|
|
||||||
- name: Setup Node.js with Yarn Caching
|
- name: Setup Node.js with Yarn Caching
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: yarn
|
cache: yarn
|
||||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
|||||||
lock:
|
lock:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v3
|
- uses: dessant/lock-threads@v4
|
||||||
with:
|
with:
|
||||||
issue-inactive-days: 90
|
issue-inactive-days: 90
|
||||||
pr-inactive-days: 30
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v6
|
- uses: actions/stale@v8
|
||||||
with:
|
with:
|
||||||
close-issue-message: >
|
close-issue-message: >
|
||||||
This issue has been automatically closed due to lack of activity. In an
|
This issue has been automatically closed due to lack of activity. In an
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
<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" />
|
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||||
<p></p>
|
<p></p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,8 +23,9 @@ django-filter
|
|||||||
django-graphiql-debug-toolbar
|
django-graphiql-debug-toolbar
|
||||||
|
|
||||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
# 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
|
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||||
django-mptt
|
django-mptt==0.14.0
|
||||||
|
|
||||||
# Context managers for PostgreSQL advisory locks
|
# Context managers for PostgreSQL advisory locks
|
||||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
# 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
|
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||||
PyYAML
|
PyYAML
|
||||||
|
|
||||||
|
# Requests
|
||||||
|
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||||
|
requests
|
||||||
|
|
||||||
# Sentry SDK
|
# Sentry SDK
|
||||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||||
sentry-sdk
|
sentry-sdk
|
||||||
|
@ -342,8 +342,10 @@
|
|||||||
"100gbase-x-qsfpdd",
|
"100gbase-x-qsfpdd",
|
||||||
"200gbase-x-qsfp56",
|
"200gbase-x-qsfp56",
|
||||||
"200gbase-x-qsfpdd",
|
"200gbase-x-qsfpdd",
|
||||||
|
"400gbase-x-qsfp112",
|
||||||
"400gbase-x-qsfpdd",
|
"400gbase-x-qsfpdd",
|
||||||
"400gbase-x-osfp",
|
"400gbase-x-osfp",
|
||||||
|
"400gbase-x-osfp-rhs",
|
||||||
"400gbase-x-cdfp",
|
"400gbase-x-cdfp",
|
||||||
"400gbase-x-cfp8",
|
"400gbase-x-cfp8",
|
||||||
"800gbase-x-qsfpdd",
|
"800gbase-x-qsfpdd",
|
||||||
|
@ -87,3 +87,24 @@ The following colors are supported:
|
|||||||
* `gray`
|
* `gray`
|
||||||
* `black`
|
* `black`
|
||||||
* `white`
|
* `white`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROTECTION_RULES
|
||||||
|
|
||||||
|
!!! tip "Dynamic Configuration Parameter"
|
||||||
|
|
||||||
|
This is a mapping of models to [custom validators](../customization/custom-validation.md) against which an object is evaluated immediately prior to its deletion. If validation fails, the object is not deleted. An example is provided below:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PROTECTION_RULES = {
|
||||||
|
"dcim.site": [
|
||||||
|
{
|
||||||
|
"status": {
|
||||||
|
"eq": "decommissioning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"my_plugin.validators.Validator1",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
|||||||
{
|
{
|
||||||
'widget': 'extras.ObjectCountsWidget',
|
'widget': 'extras.ObjectCountsWidget',
|
||||||
'width': 4,
|
'width': 4,
|
||||||
'height': 2,
|
'height': 3,
|
||||||
'title': 'Organization',
|
'title': 'Organization',
|
||||||
'config': {
|
'config': {
|
||||||
'models': [
|
'models': [
|
||||||
@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
'widget': 'extras.ObjectCountsWidget',
|
'widget': 'extras.ObjectCountsWidget',
|
||||||
|
'width': 4,
|
||||||
|
'height': 3,
|
||||||
'title': 'IPAM',
|
'title': 'IPAM',
|
||||||
'color': 'blue',
|
'color': 'blue',
|
||||||
'config': {
|
'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
|
## ENFORCE_GLOBAL_UNIQUE
|
||||||
|
|
||||||
!!! tip "Dynamic Configuration Parameter"
|
!!! 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.
|
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
|
## Running Custom Scripts
|
||||||
|
|
||||||
!!! note
|
!!! 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
@ -26,6 +26,8 @@ The `CustomValidator` class supports several validation types:
|
|||||||
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
|
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
|
||||||
* `required`: A value must be specified
|
* `required`: A value must be specified
|
||||||
* `prohibited`: A value must _not_ be specified
|
* `prohibited`: A value must _not_ be specified
|
||||||
|
* `eq`: A value must be equal to the specified value
|
||||||
|
* `neq`: A value must _not_ be equal to the specified value
|
||||||
|
|
||||||
The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`.
|
The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`.
|
||||||
|
|
||||||
|
@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
|||||||
## Running Reports
|
## Running Reports
|
||||||
|
|
||||||
!!! note
|
!!! 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.
|
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.
|
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.
|
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>
|
<h5 class="card-header">{% trans "Circuit List" %}</h5>
|
||||||
|
|
||||||
{# A longer string with a context variable #}
|
{# 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?
|
There are {count} circuits. Would you like to continue?
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
```
|
```
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{style="height: 100px; margin-bottom: 3em"}
|
{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.
|
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
|
!!! warning
|
||||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
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
|
## 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`.
|
`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
|
# 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.
|
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>
|
<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>
|
||||||
|
@ -77,6 +77,9 @@ If selected, this component will be treated as if a cable has been connected.
|
|||||||
|
|
||||||
Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
|
Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
|
||||||
|
|
||||||
### Bridged Interface
|
### Bridged Interface
|
||||||
|
|
||||||
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.
|
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.
|
||||||
|
@ -16,6 +16,9 @@ The interface's name. Must be unique to the assigned VM.
|
|||||||
|
|
||||||
Identifies the parent interface of a subinterface (e.g. used to employ encapsulation).
|
Identifies the parent interface of a subinterface (e.g. used to employ encapsulation).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
|
||||||
|
|
||||||
### Bridged Interface
|
### Bridged Interface
|
||||||
|
|
||||||
An interface on the same VM with which this interface is bridged.
|
An interface on the same VM with which this interface is bridged.
|
||||||
|
23
docs/plugins/development/data-backends.md
Normal file
23
docs/plugins/development/data-backends.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Data Backends
|
||||||
|
|
||||||
|
[Data sources](../../models/core/datasource.md) can be defined to reference data which exists on systems of record outside NetBox, such as a git repository or Amazon S3 bucket. Plugins can register their own backend classes to introduce support for additional resource types. This is done by subclassing NetBox's `DataBackend` class.
|
||||||
|
|
||||||
|
```python title="data_backends.py"
|
||||||
|
from netbox.data_backends import DataBackend
|
||||||
|
|
||||||
|
class MyDataBackend(DataBackend):
|
||||||
|
name = 'mybackend'
|
||||||
|
label = 'My Backend'
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
To register one or more data backends with NetBox, define a list named `backends` at the end of this file:
|
||||||
|
|
||||||
|
```python title="data_backends.py"
|
||||||
|
backends = [MyDataBackend]
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
|
||||||
|
|
||||||
|
::: core.data_backends.DataBackend
|
@ -109,6 +109,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
|||||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||||
| `queues` | A list of custom background task queues to create |
|
| `queues` | A list of custom background task queues to create |
|
||||||
| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
|
| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
|
||||||
|
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
|
||||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||||
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
|
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
|
||||||
|
@ -1,6 +1,91 @@
|
|||||||
# NetBox v3.6
|
# NetBox v3.6
|
||||||
|
|
||||||
## v3.6.2 (FUTURE)
|
## v3.6.5 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -27,7 +112,7 @@
|
|||||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
* [#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
|
* [#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
|
* [#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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -136,6 +136,7 @@ nav:
|
|||||||
- Forms: 'plugins/development/forms.md'
|
- Forms: 'plugins/development/forms.md'
|
||||||
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||||
- Search: 'plugins/development/search.md'
|
- Search: 'plugins/development/search.md'
|
||||||
|
- Data Backends: 'plugins/development/data-backends.md'
|
||||||
- REST API: 'plugins/development/rest-api.md'
|
- REST API: 'plugins/development/rest-api.md'
|
||||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||||
|
@ -85,7 +85,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
'circuit_count',
|
'circuit_count',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -137,7 +137,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = ['id', 'name', 'slug', 'description']
|
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||||
|
|
||||||
|
|
||||||
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||||
@ -154,12 +154,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
|||||||
provider_account_id = django_filters.ModelMultipleChoiceFilter(
|
provider_account_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='provider_account',
|
field_name='provider_account',
|
||||||
queryset=ProviderAccount.objects.all(),
|
queryset=ProviderAccount.objects.all(),
|
||||||
label=_('ProviderAccount (ID)'),
|
label=_('Provider account (ID)'),
|
||||||
)
|
)
|
||||||
provider_network_id = django_filters.ModelMultipleChoiceFilter(
|
provider_network_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='terminations__provider_network',
|
field_name='terminations__provider_network',
|
||||||
queryset=ProviderNetwork.objects.all(),
|
queryset=ProviderNetwork.objects.all(),
|
||||||
label=_('ProviderNetwork (ID)'),
|
label=_('Provider network (ID)'),
|
||||||
)
|
)
|
||||||
type_id = django_filters.ModelMultipleChoiceFilter(
|
type_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=CircuitType.objects.all(),
|
queryset=CircuitType.objects.all(),
|
||||||
|
@ -7,7 +7,7 @@ from ipam.models import ASN
|
|||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import add_blank_choice
|
from utilities.forms import add_blank_choice
|
||||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -91,6 +91,10 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
|
|
||||||
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
|
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
color = ColorField(
|
||||||
|
label=_('Color'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
label=_('Description'),
|
label=_('Description'),
|
||||||
max_length=200,
|
max_length=200,
|
||||||
@ -99,9 +103,9 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('description',)),
|
(None, ('color', 'description')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('description',)
|
nullable_fields = ('color', 'description')
|
||||||
|
|
||||||
|
|
||||||
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
@ -3,6 +3,7 @@ from django import forms
|
|||||||
from circuits.choices import CircuitStatusChoices
|
from circuits.choices import CircuitStatusChoices
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
@ -64,7 +65,10 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = ('name', 'slug', 'description', 'tags')
|
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||||
|
help_texts = {
|
||||||
|
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CircuitImportForm(NetBoxModelImportForm):
|
class CircuitImportForm(NetBoxModelImportForm):
|
||||||
|
@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
|||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
|
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -88,7 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
|
|||||||
label=_('Provider')
|
label=_('Provider')
|
||||||
)
|
)
|
||||||
service_id = forms.CharField(
|
service_id = forms.CharField(
|
||||||
label=_('Service id'),
|
label=_('Service ID'),
|
||||||
max_length=100,
|
max_length=100,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -97,8 +97,17 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
|
class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
|
fieldsets = (
|
||||||
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
|
(_('Attributes'), ('color',)),
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
color = ColorField(
|
||||||
|
label=_('Color'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
|
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||||
model = Circuit
|
model = Circuit
|
||||||
|
@ -76,14 +76,14 @@ class CircuitTypeForm(NetBoxModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(_('Circuit Type'), (
|
(_('Circuit Type'), (
|
||||||
'name', 'slug', 'description', 'tags',
|
'name', 'slug', 'color', 'description', 'tags',
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'slug', 'description', 'tags',
|
'name', 'slug', 'color', 'description', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal file
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.5 on 2023-10-20 21:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import utilities.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('circuits', '0042_provideraccount'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='circuittype',
|
||||||
|
name='color',
|
||||||
|
field=utilities.fields.ColorField(blank=True, max_length=6),
|
||||||
|
),
|
||||||
|
]
|
@ -7,6 +7,7 @@ from circuits.choices import *
|
|||||||
from dcim.models import CabledObjectModel
|
from dcim.models import CabledObjectModel
|
||||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||||
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
|
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
|
||||||
|
from utilities.fields import ColorField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Circuit',
|
'Circuit',
|
||||||
@ -20,6 +21,11 @@ class CircuitType(OrganizationalModel):
|
|||||||
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
|
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
|
||||||
"Long Haul," "Metro," or "Out-of-Band".
|
"Long Haul," "Metro," or "Out-of-Band".
|
||||||
"""
|
"""
|
||||||
|
color = ColorField(
|
||||||
|
verbose_name=_('color'),
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('circuits:circuittype', args=[self.pk])
|
return reverse('circuits:circuittype', args=[self.pk])
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable):
|
|||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
)
|
)
|
||||||
|
color = columns.ColorColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='circuits:circuittype_list'
|
url_name='circuits:circuittype_list'
|
||||||
)
|
)
|
||||||
@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from core.choices import *
|
|||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||||
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
|
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
|
||||||
|
from netbox.utils import get_data_backend_choices
|
||||||
from users.api.nested_serializers import NestedUserSerializer
|
from users.api.nested_serializers import NestedUserSerializer
|
||||||
from .nested_serializers import *
|
from .nested_serializers import *
|
||||||
|
|
||||||
@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
|||||||
view_name='core-api:datasource-detail'
|
view_name='core-api:datasource-detail'
|
||||||
)
|
)
|
||||||
type = ChoiceField(
|
type = ChoiceField(
|
||||||
choices=DataSourceTypeChoices
|
choices=get_data_backend_choices()
|
||||||
)
|
)
|
||||||
status = ChoiceField(
|
status = ChoiceField(
|
||||||
choices=DataSourceStatusChoices,
|
choices=DataSourceStatusChoices,
|
||||||
@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
||||||
'started', 'completed', 'user', 'data', 'job_id',
|
'started', 'completed', 'user', 'data', 'error', 'job_id',
|
||||||
]
|
]
|
||||||
|
@ -7,18 +7,6 @@ from utilities.choices import ChoiceSet
|
|||||||
# Data sources
|
# Data sources
|
||||||
#
|
#
|
||||||
|
|
||||||
class DataSourceTypeChoices(ChoiceSet):
|
|
||||||
LOCAL = 'local'
|
|
||||||
GIT = 'git'
|
|
||||||
AMAZON_S3 = 'amazon-s3'
|
|
||||||
|
|
||||||
CHOICES = (
|
|
||||||
(LOCAL, _('Local'), 'gray'),
|
|
||||||
(GIT, _('Git'), 'blue'),
|
|
||||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DataSourceStatusChoices(ChoiceSet):
|
class DataSourceStatusChoices(ChoiceSet):
|
||||||
NEW = 'new'
|
NEW = 'new'
|
||||||
QUEUED = 'queued'
|
QUEUED = 'queued'
|
||||||
|
@ -10,61 +10,24 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from netbox.registry import registry
|
from netbox.data_backends import DataBackend
|
||||||
from .choices import DataSourceTypeChoices
|
from netbox.utils import register_data_backend
|
||||||
from .exceptions import SyncError
|
from .exceptions import SyncError
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'LocalBackend',
|
|
||||||
'GitBackend',
|
'GitBackend',
|
||||||
|
'LocalBackend',
|
||||||
'S3Backend',
|
'S3Backend',
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger('netbox.data_backends')
|
logger = logging.getLogger('netbox.data_backends')
|
||||||
|
|
||||||
|
|
||||||
def register_backend(name):
|
@register_data_backend()
|
||||||
"""
|
|
||||||
Decorator for registering a DataBackend class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _wrapper(cls):
|
|
||||||
registry['data_backends'][name] = cls
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return _wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class DataBackend:
|
|
||||||
parameters = {}
|
|
||||||
sensitive_parameters = []
|
|
||||||
|
|
||||||
# Prevent Django's template engine from calling the backend
|
|
||||||
# class when referenced via DataSource.backend_class
|
|
||||||
do_not_call_in_templates = True
|
|
||||||
|
|
||||||
def __init__(self, url, **kwargs):
|
|
||||||
self.url = url
|
|
||||||
self.params = kwargs
|
|
||||||
self.config = self.init_config()
|
|
||||||
|
|
||||||
def init_config(self):
|
|
||||||
"""
|
|
||||||
Hook to initialize the instance's configuration.
|
|
||||||
"""
|
|
||||||
return
|
|
||||||
|
|
||||||
@property
|
|
||||||
def url_scheme(self):
|
|
||||||
return urlparse(self.url).scheme.lower()
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def fetch(self):
|
|
||||||
raise NotImplemented()
|
|
||||||
|
|
||||||
|
|
||||||
@register_backend(DataSourceTypeChoices.LOCAL)
|
|
||||||
class LocalBackend(DataBackend):
|
class LocalBackend(DataBackend):
|
||||||
|
name = 'local'
|
||||||
|
label = _('Local')
|
||||||
|
is_local = True
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def fetch(self):
|
def fetch(self):
|
||||||
@ -74,20 +37,22 @@ class LocalBackend(DataBackend):
|
|||||||
yield local_path
|
yield local_path
|
||||||
|
|
||||||
|
|
||||||
@register_backend(DataSourceTypeChoices.GIT)
|
@register_data_backend()
|
||||||
class GitBackend(DataBackend):
|
class GitBackend(DataBackend):
|
||||||
|
name = 'git'
|
||||||
|
label = 'Git'
|
||||||
parameters = {
|
parameters = {
|
||||||
'username': forms.CharField(
|
'username': forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Username'),
|
label=_('Username'),
|
||||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
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(
|
'password': forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
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(
|
'branch': forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@ -144,8 +109,10 @@ class GitBackend(DataBackend):
|
|||||||
local_path.cleanup()
|
local_path.cleanup()
|
||||||
|
|
||||||
|
|
||||||
@register_backend(DataSourceTypeChoices.AMAZON_S3)
|
@register_data_backend()
|
||||||
class S3Backend(DataBackend):
|
class S3Backend(DataBackend):
|
||||||
|
name = 'amazon-s3'
|
||||||
|
label = 'Amazon S3'
|
||||||
parameters = {
|
parameters = {
|
||||||
'aws_access_key_id': forms.CharField(
|
'aws_access_key_id': forms.CharField(
|
||||||
label=_('AWS access key ID'),
|
label=_('AWS access key ID'),
|
||||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
|||||||
import django_filters
|
import django_filters
|
||||||
|
|
||||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||||
|
from netbox.utils import get_data_backend_choices
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -16,7 +17,7 @@ __all__ = (
|
|||||||
|
|
||||||
class DataSourceFilterSet(NetBoxModelFilterSet):
|
class DataSourceFilterSet(NetBoxModelFilterSet):
|
||||||
type = django_filters.MultipleChoiceFilter(
|
type = django_filters.MultipleChoiceFilter(
|
||||||
choices=DataSourceTypeChoices,
|
choices=get_data_backend_choices,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
status = django_filters.MultipleChoiceFilter(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.choices import DataSourceTypeChoices
|
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from utilities.forms import add_blank_choice
|
from netbox.utils import get_data_backend_choices
|
||||||
from utilities.forms.fields import CommentField
|
from utilities.forms.fields import CommentField
|
||||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||||
|
|
||||||
@ -16,9 +15,8 @@ __all__ = (
|
|||||||
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
choices=add_blank_choice(DataSourceTypeChoices),
|
choices=get_data_backend_choices,
|
||||||
required=False,
|
required=False
|
||||||
initial=''
|
|
||||||
)
|
)
|
||||||
enabled = forms.NullBooleanField(
|
enabled = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -8,6 +8,7 @@ from core.models import *
|
|||||||
from extras.forms.mixins import SavedFiltersMixin
|
from extras.forms.mixins import SavedFiltersMixin
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
|
from netbox.utils import get_data_backend_choices
|
||||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
||||||
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
|
||||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||||
@ -27,7 +28,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
choices=DataSourceTypeChoices,
|
choices=get_data_backend_choices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
status = forms.MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
|
@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin
|
|||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
from netbox.utils import get_data_backend_choices
|
||||||
from utilities.forms import get_field_value
|
from utilities.forms import get_field_value
|
||||||
from utilities.forms.fields import CommentField
|
from utilities.forms.fields import CommentField
|
||||||
from utilities.forms.widgets import HTMXSelect
|
from utilities.forms.widgets import HTMXSelect
|
||||||
@ -18,6 +19,10 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
class DataSourceForm(NetBoxModelForm):
|
class DataSourceForm(NetBoxModelForm):
|
||||||
|
type = forms.ChoiceField(
|
||||||
|
choices=get_data_backend_choices,
|
||||||
|
widget=HTMXSelect()
|
||||||
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm):
|
|||||||
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
|
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'type': HTMXSelect(),
|
|
||||||
'ignore_rules': forms.Textarea(
|
'ignore_rules': forms.Textarea(
|
||||||
attrs={
|
attrs={
|
||||||
'rows': 5,
|
'rows': 5,
|
||||||
@ -56,6 +60,7 @@ class DataSourceForm(NetBoxModelForm):
|
|||||||
|
|
||||||
# Add backend-specific form fields
|
# Add backend-specific form fields
|
||||||
self.backend_fields = []
|
self.backend_fields = []
|
||||||
|
if backend:
|
||||||
for name, form_field in backend.parameters.items():
|
for name, form_field in backend.parameters.items():
|
||||||
field_name = f'backend_{name}'
|
field_name = f'backend_{name}'
|
||||||
self.backend_fields.append(field_name)
|
self.backend_fields.append(field_name)
|
||||||
|
@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
|
|||||||
job.terminate()
|
job.terminate()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
|
||||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||||
if type(e) in (SyncError, JobTimeoutException):
|
if type(e) in (SyncError, JobTimeoutException):
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.6 on 2023-10-20 17:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0005_job_created_auto_now'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='datasource',
|
||||||
|
name='type',
|
||||||
|
field=models.CharField(max_length=50),
|
||||||
|
),
|
||||||
|
]
|
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.6 on 2023-10-23 20:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0006_datasource_type_remove_choices'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='job',
|
||||||
|
name='error',
|
||||||
|
field=models.TextField(blank=True, editable=False),
|
||||||
|
),
|
||||||
|
]
|
@ -45,9 +45,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
)
|
)
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
verbose_name=_('type'),
|
verbose_name=_('type'),
|
||||||
max_length=50,
|
max_length=50
|
||||||
choices=DataSourceTypeChoices,
|
|
||||||
default=DataSourceTypeChoices.LOCAL
|
|
||||||
)
|
)
|
||||||
source_url = models.CharField(
|
source_url = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
@ -96,8 +94,9 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
def docs_url(self):
|
def docs_url(self):
|
||||||
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||||
|
|
||||||
def get_type_color(self):
|
def get_type_display(self):
|
||||||
return DataSourceTypeChoices.colors.get(self.type)
|
if backend := registry['data_backends'].get(self.type):
|
||||||
|
return backend.label
|
||||||
|
|
||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return DataSourceStatusChoices.colors.get(self.status)
|
return DataSourceStatusChoices.colors.get(self.status)
|
||||||
@ -110,10 +109,6 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
def backend_class(self):
|
def backend_class(self):
|
||||||
return registry['data_backends'].get(self.type)
|
return registry['data_backends'].get(self.type)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_local(self):
|
|
||||||
return self.type == DataSourceTypeChoices.LOCAL
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_for_sync(self):
|
def ready_for_sync(self):
|
||||||
return self.enabled and self.status not in (
|
return self.enabled and self.status not in (
|
||||||
@ -123,8 +118,14 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
|
# Validate data backend type
|
||||||
|
if self.type and self.type not in registry['data_backends']:
|
||||||
|
raise ValidationError({
|
||||||
|
'type': _("Unknown backend type: {type}".format(type=self.type))
|
||||||
|
})
|
||||||
|
|
||||||
# Ensure URL scheme matches selected type
|
# Ensure URL scheme matches selected type
|
||||||
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
|
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
|
||||||
})
|
})
|
||||||
|
@ -92,6 +92,11 @@ class Job(models.Model):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
error = models.TextField(
|
||||||
|
verbose_name=_('error'),
|
||||||
|
editable=False,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
job_id = models.UUIDField(
|
job_id = models.UUIDField(
|
||||||
verbose_name=_('job ID'),
|
verbose_name=_('job ID'),
|
||||||
unique=True
|
unique=True
|
||||||
@ -158,7 +163,7 @@ class Job(models.Model):
|
|||||||
# Handle webhooks
|
# Handle webhooks
|
||||||
self.trigger_webhooks(event=EVENT_JOB_START)
|
self.trigger_webhooks(event=EVENT_JOB_START)
|
||||||
|
|
||||||
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
|
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
|
||||||
"""
|
"""
|
||||||
Mark the job as completed, optionally specifying a particular termination status.
|
Mark the job as completed, optionally specifying a particular termination status.
|
||||||
"""
|
"""
|
||||||
@ -168,6 +173,8 @@ class Job(models.Model):
|
|||||||
|
|
||||||
# Mark the job as completed
|
# Mark the job as completed
|
||||||
self.status = status
|
self.status = status
|
||||||
|
if error:
|
||||||
|
self.error = error
|
||||||
self.completed = timezone.now()
|
self.completed = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
20
netbox/core/tables/columns.py
Normal file
20
netbox/core/tables/columns.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import django_tables2 as tables
|
||||||
|
|
||||||
|
from netbox.registry import registry
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'BackendTypeColumn',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackendTypeColumn(tables.Column):
|
||||||
|
"""
|
||||||
|
Display a data backend type.
|
||||||
|
"""
|
||||||
|
def render(self, value):
|
||||||
|
if backend := registry['data_backends'].get(value):
|
||||||
|
return backend.label
|
||||||
|
return value
|
||||||
|
|
||||||
|
def value(self, value):
|
||||||
|
return value
|
@ -3,6 +3,7 @@ import django_tables2 as tables
|
|||||||
|
|
||||||
from core.models import *
|
from core.models import *
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
from .columns import BackendTypeColumn
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DataFileTable',
|
'DataFileTable',
|
||||||
@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
|
|||||||
verbose_name=_('Name'),
|
verbose_name=_('Name'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
type = columns.ChoiceFieldColumn(
|
type = BackendTypeColumn(
|
||||||
verbose_name=_('Type'),
|
verbose_name=_('Type')
|
||||||
)
|
)
|
||||||
status = columns.ChoiceFieldColumn(
|
status = columns.ChoiceFieldColumn(
|
||||||
verbose_name=_('Status'),
|
verbose_name=_('Status'),
|
||||||
@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = DataSource
|
model = DataSource
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
|
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
|
||||||
'last_updated', 'file_count',
|
'created', 'last_updated', 'file_count',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
|
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ class JobTable(NetBoxTable):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||||
'completed', 'user', 'job_id',
|
'completed', 'user', 'error', 'job_id',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||||
|
@ -2,7 +2,6 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from utilities.testing import APITestCase, APIViewTestCases
|
from utilities.testing import APITestCase, APIViewTestCases
|
||||||
from ..choices import *
|
|
||||||
from ..models import *
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
data_sources = (
|
data_sources = (
|
||||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||||
)
|
)
|
||||||
DataSource.objects.bulk_create(data_sources)
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
cls.create_data = [
|
cls.create_data = [
|
||||||
{
|
{
|
||||||
'name': 'Data Source 4',
|
'name': 'Data Source 4',
|
||||||
'type': DataSourceTypeChoices.GIT,
|
'type': 'git',
|
||||||
'source_url': 'https://example.com/git/source4'
|
'source_url': 'https://example.com/git/source4'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Data Source 5',
|
'name': 'Data Source 5',
|
||||||
'type': DataSourceTypeChoices.GIT,
|
'type': 'git',
|
||||||
'source_url': 'https://example.com/git/source5'
|
'source_url': 'https://example.com/git/source5'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Data Source 6',
|
'name': 'Data Source 6',
|
||||||
'type': DataSourceTypeChoices.GIT,
|
'type': 'git',
|
||||||
'source_url': 'https://example.com/git/source6'
|
'source_url': 'https://example.com/git/source6'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -63,7 +62,7 @@ class DataFileTest(
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
datasource = DataSource.objects.create(
|
datasource = DataSource.objects.create(
|
||||||
name='Data Source 1',
|
name='Data Source 1',
|
||||||
type=DataSourceTypeChoices.LOCAL,
|
type='local',
|
||||||
source_url='file:///var/tmp/source1/'
|
source_url='file:///var/tmp/source1/'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
data_sources = (
|
data_sources = (
|
||||||
DataSource(
|
DataSource(
|
||||||
name='Data Source 1',
|
name='Data Source 1',
|
||||||
type=DataSourceTypeChoices.LOCAL,
|
type='local',
|
||||||
source_url='file:///var/tmp/source1/',
|
source_url='file:///var/tmp/source1/',
|
||||||
status=DataSourceStatusChoices.NEW,
|
status=DataSourceStatusChoices.NEW,
|
||||||
enabled=True
|
enabled=True
|
||||||
),
|
),
|
||||||
DataSource(
|
DataSource(
|
||||||
name='Data Source 2',
|
name='Data Source 2',
|
||||||
type=DataSourceTypeChoices.LOCAL,
|
type='local',
|
||||||
source_url='file:///var/tmp/source2/',
|
source_url='file:///var/tmp/source2/',
|
||||||
status=DataSourceStatusChoices.SYNCING,
|
status=DataSourceStatusChoices.SYNCING,
|
||||||
enabled=True
|
enabled=True
|
||||||
),
|
),
|
||||||
DataSource(
|
DataSource(
|
||||||
name='Data Source 3',
|
name='Data Source 3',
|
||||||
type=DataSourceTypeChoices.GIT,
|
type='git',
|
||||||
source_url='https://example.com/git/source3',
|
source_url='https://example.com/git/source3',
|
||||||
status=DataSourceStatusChoices.COMPLETED,
|
status=DataSourceStatusChoices.COMPLETED,
|
||||||
enabled=False
|
enabled=False
|
||||||
@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_type(self):
|
def test_type(self):
|
||||||
params = {'type': [DataSourceTypeChoices.LOCAL]}
|
params = {'type': ['local']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_enabled(self):
|
def test_enabled(self):
|
||||||
@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
data_sources = (
|
data_sources = (
|
||||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||||
)
|
)
|
||||||
DataSource.objects.bulk_create(data_sources)
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from utilities.testing import ViewTestCases, create_tags
|
from utilities.testing import ViewTestCases, create_tags
|
||||||
from ..choices import *
|
|
||||||
from ..models import *
|
from ..models import *
|
||||||
|
|
||||||
|
|
||||||
@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
data_sources = (
|
data_sources = (
|
||||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||||
)
|
)
|
||||||
DataSource.objects.bulk_create(data_sources)
|
DataSource.objects.bulk_create(data_sources)
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'name': 'Data Source X',
|
'name': 'Data Source X',
|
||||||
'type': DataSourceTypeChoices.GIT,
|
'type': 'git',
|
||||||
'source_url': 'http:///exmaple/com/foo/bar/',
|
'source_url': 'http:///exmaple/com/foo/bar/',
|
||||||
'description': 'Something',
|
'description': 'Something',
|
||||||
'comments': 'Foo bar baz',
|
'comments': 'Foo bar baz',
|
||||||
@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
f"name,type,source_url,enabled",
|
"name,type,source_url,enabled",
|
||||||
f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
"Data Source 4,local,file:///var/tmp/source4/,true",
|
||||||
f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
"Data Source 5,local,file:///var/tmp/source4/,true",
|
||||||
f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
|
"Data Source 6,git,http:///exmaple/com/foo/bar/,false",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
@ -60,7 +59,7 @@ class DataFileTestCase(
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
datasource = DataSource.objects.create(
|
datasource = DataSource.objects.create(
|
||||||
name='Data Source 1',
|
name='Data Source 1',
|
||||||
type=DataSourceTypeChoices.LOCAL,
|
type='local',
|
||||||
source_url='file:///var/tmp/source1/'
|
source_url='file:///var/tmp/source1/'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -100,7 +100,9 @@ class DataFileListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.DataFileFilterSet
|
filterset = filtersets.DataFileFilterSet
|
||||||
filterset_form = forms.DataFileFilterForm
|
filterset_form = forms.DataFileFilterForm
|
||||||
table = tables.DataFileTable
|
table = tables.DataFileTable
|
||||||
actions = ('bulk_delete',)
|
actions = {
|
||||||
|
'bulk_delete': {'delete'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DataFile)
|
@register_model_view(DataFile)
|
||||||
@ -128,7 +130,10 @@ class JobListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.JobFilterSet
|
filterset = filtersets.JobFilterSet
|
||||||
filterset_form = forms.JobFilterForm
|
filterset_form = forms.JobFilterForm
|
||||||
table = tables.JobTable
|
table = tables.JobTable
|
||||||
actions = ('export', 'delete', 'bulk_delete')
|
actions = {
|
||||||
|
'export': {'view'},
|
||||||
|
'bulk_delete': {'delete'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class JobView(generic.ObjectView):
|
class JobView(generic.ObjectView):
|
||||||
|
@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
|
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
|
||||||
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
'front_image', 'rear_image', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
|
'device_count', 'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
|
||||||
'power_outlet_template_count', 'interface_template_count', 'front_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',
|
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
|
||||||
'inventory_item_template_count',
|
'inventory_item_template_count',
|
||||||
|
@ -20,10 +20,11 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
|||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||||
from netbox.api.renderers import TextRenderer
|
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.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
from . import serializers
|
from . import serializers
|
||||||
@ -98,7 +99,7 @@ class PassThroughPortMixin(object):
|
|||||||
# Regions
|
# Regions
|
||||||
#
|
#
|
||||||
|
|
||||||
class RegionViewSet(NetBoxModelViewSet):
|
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = Region.objects.add_related_count(
|
queryset = Region.objects.add_related_count(
|
||||||
Region.objects.all(),
|
Region.objects.all(),
|
||||||
Site,
|
Site,
|
||||||
@ -114,7 +115,7 @@ class RegionViewSet(NetBoxModelViewSet):
|
|||||||
# Site groups
|
# Site groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = SiteGroup.objects.add_related_count(
|
queryset = SiteGroup.objects.add_related_count(
|
||||||
SiteGroup.objects.all(),
|
SiteGroup.objects.all(),
|
||||||
Site,
|
Site,
|
||||||
@ -149,7 +150,7 @@ class SiteViewSet(NetBoxModelViewSet):
|
|||||||
# Locations
|
# Locations
|
||||||
#
|
#
|
||||||
|
|
||||||
class LocationViewSet(NetBoxModelViewSet):
|
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = Location.objects.add_related_count(
|
queryset = Location.objects.add_related_count(
|
||||||
Location.objects.add_related_count(
|
Location.objects.add_related_count(
|
||||||
Location.objects.all(),
|
Location.objects.all(),
|
||||||
@ -350,7 +351,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||||
@ -505,6 +506,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.InterfaceFilterSet
|
filterset_class = filtersets.InterfaceFilterSet
|
||||||
brief_prefetch_fields = ['device']
|
brief_prefetch_fields = ['device']
|
||||||
|
|
||||||
|
def get_bulk_destroy_queryset(self):
|
||||||
|
# Ensure child interfaces are deleted prior to their parents
|
||||||
|
return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name'))
|
||||||
|
|
||||||
|
|
||||||
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||||
queryset = FrontPort.objects.prefetch_related(
|
queryset = FrontPort.objects.prefetch_related(
|
||||||
@ -538,7 +543,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
|
|||||||
brief_prefetch_fields = ['device']
|
brief_prefetch_fields = ['device']
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||||
serializer_class = serializers.InventoryItemSerializer
|
serializer_class = serializers.InventoryItemSerializer
|
||||||
filterset_class = filtersets.InventoryItemFilterSet
|
filterset_class = filtersets.InventoryItemFilterSet
|
||||||
|
@ -80,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
|
|||||||
WIDTH_23IN = 23
|
WIDTH_23IN = 23
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(WIDTH_10IN, _('10 inches')),
|
(WIDTH_10IN, _('{n} inches').format(n=10)),
|
||||||
(WIDTH_19IN, _('19 inches')),
|
(WIDTH_19IN, _('{n} inches').format(n=19)),
|
||||||
(WIDTH_21IN, _('21 inches')),
|
(WIDTH_21IN, _('{n} inches').format(n=21)),
|
||||||
(WIDTH_23IN, _('23 inches')),
|
(WIDTH_23IN, _('{n} inches').format(n=23)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||||
|
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||||
|
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||||
@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||||
|
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||||
|
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||||
|
@ -496,7 +496,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
|
||||||
|
'airflow', 'weight', 'weight_unit',
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
@ -1745,6 +1746,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
method='filter_by_cable_end_b',
|
method='filter_by_cable_end_b',
|
||||||
field_name='terminations__termination_id'
|
field_name='terminations__termination_id'
|
||||||
)
|
)
|
||||||
|
unterminated = django_filters.BooleanFilter(
|
||||||
|
method='_unterminated',
|
||||||
|
label=_('Unterminated'),
|
||||||
|
)
|
||||||
type = django_filters.MultipleChoiceFilter(
|
type = django_filters.MultipleChoiceFilter(
|
||||||
choices=CableTypeChoices
|
choices=CableTypeChoices
|
||||||
)
|
)
|
||||||
@ -1812,6 +1817,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
|||||||
# Filter by termination id and cable_end type
|
# Filter by termination id and cable_end type
|
||||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
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):
|
class CableTerminationFilterSet(BaseFilterSet):
|
||||||
termination_type = ContentTypeFilter()
|
termination_type = ContentTypeFilter()
|
||||||
|
@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
widget=BulkEditNullBooleanSelect(),
|
widget=BulkEditNullBooleanSelect(),
|
||||||
label=_('Is full depth')
|
label=_('Is full depth')
|
||||||
)
|
)
|
||||||
|
exclude_from_utilization = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect(),
|
||||||
|
label=_('Exclude from utilization')
|
||||||
|
)
|
||||||
airflow = forms.ChoiceField(
|
airflow = forms.ChoiceField(
|
||||||
label=_('Airflow'),
|
label=_('Airflow'),
|
||||||
choices=add_blank_choice(DeviceAirflowChoices),
|
choices=add_blank_choice(DeviceAirflowChoices),
|
||||||
@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(_('Device Type'), ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
|
(_('Device Type'), (
|
||||||
|
'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
|
||||||
|
'airflow', 'description',
|
||||||
|
)),
|
||||||
(_('Weight'), ('weight', 'weight_unit')),
|
(_('Weight'), ('weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
||||||
|
@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
|
|||||||
)
|
)
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'time_zone': mark_safe(
|
'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
|
model = RackRole
|
||||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||||
help_texts = {
|
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>'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -333,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
|||||||
model = DeviceRole
|
model = DeviceRole
|
||||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||||
help_texts = {
|
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 = {
|
params = {
|
||||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||||
}
|
}
|
||||||
if 'location' in data:
|
if location := data.get('location'):
|
||||||
params.update({
|
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)
|
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||||
|
|
||||||
@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
|||||||
queryset=VirtualDeviceContext.objects.all(),
|
queryset=VirtualDeviceContext.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
to_field_name='name',
|
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(
|
type = CSVChoiceField(
|
||||||
label=_('Type'),
|
label=_('Type'),
|
||||||
@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
|||||||
model = InventoryItemRole
|
model = InventoryItemRole
|
||||||
fields = ('name', 'slug', 'color', 'description')
|
fields = ('name', 'slug', 'color', 'description')
|
||||||
help_texts = {
|
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):
|
class CableImportForm(NetBoxModelImportForm):
|
||||||
# Termination A
|
# Termination A
|
||||||
side_a_device = CSVModelChoiceField(
|
side_a_device = CSVModelChoiceField(
|
||||||
label=_('Side a device'),
|
label=_('Side A device'),
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Side A device')
|
help_text=_('Device name')
|
||||||
)
|
)
|
||||||
side_a_type = CSVContentTypeField(
|
side_a_type = CSVContentTypeField(
|
||||||
label=_('Side a type'),
|
label=_('Side A type'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||||
help_text=_('Side A type')
|
help_text=_('Termination type')
|
||||||
)
|
)
|
||||||
side_a_name = forms.CharField(
|
side_a_name = forms.CharField(
|
||||||
label=_('Side a name'),
|
label=_('Side A name'),
|
||||||
help_text=_('Side A component name')
|
help_text=_('Termination name')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Termination B
|
# Termination B
|
||||||
side_b_device = CSVModelChoiceField(
|
side_b_device = CSVModelChoiceField(
|
||||||
label=_('Side b device'),
|
label=_('Side B device'),
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Side B device')
|
help_text=_('Device name')
|
||||||
)
|
)
|
||||||
side_b_type = CSVContentTypeField(
|
side_b_type = CSVContentTypeField(
|
||||||
label=_('Side b type'),
|
label=_('Side B type'),
|
||||||
queryset=ContentType.objects.all(),
|
queryset=ContentType.objects.all(),
|
||||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||||
help_text=_('Side B type')
|
help_text=_('Termination type')
|
||||||
)
|
)
|
||||||
side_b_name = forms.CharField(
|
side_b_name = forms.CharField(
|
||||||
label=_('Side b name'),
|
label=_('Side B name'),
|
||||||
help_text=_('Side B component name')
|
help_text=_('Termination name')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cable attributes
|
# Cable attributes
|
||||||
@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
|
|||||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
help_texts = {
|
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):
|
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)
|
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||||
else:
|
else:
|
||||||
termination_object = model.objects.get(device=device, name=name)
|
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")
|
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||||
|
@ -116,17 +116,17 @@ class ModuleCommonForm(forms.Form):
|
|||||||
# It is not possible to adopt components already belonging to a module
|
# It is not possible to adopt components already belonging to a module
|
||||||
if adopt_components and existing_item and existing_item.module:
|
if adopt_components and existing_item and existing_item.module:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format(
|
_("Cannot adopt {model} {name} as it already belongs to a module").format(
|
||||||
name=template.component_model.__name__,
|
model=template.component_model.__name__,
|
||||||
resolved_name=resolved_name
|
name=resolved_name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we are not adopting components we error if the component exists
|
# If we are not adopting components we error if the component exists
|
||||||
if not adopt_components and resolved_name in installed_components:
|
if not adopt_components and resolved_name in installed_components:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_("{name} - {resolved_name} already exists").format(
|
_("A {model} named {name} already exists").format(
|
||||||
name=template.component_model.__name__,
|
model=template.component_model.__name__,
|
||||||
resolved_name=resolved_name
|
name=resolved_name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Device type')
|
label=_('Device type')
|
||||||
)
|
)
|
||||||
role_id = DynamicModelMultipleChoiceField(
|
device_role_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=DeviceRole.objects.all(),
|
queryset=DeviceRole.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Device role')
|
label=_('Device role')
|
||||||
@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
|
(_('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')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
)
|
)
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
choices=add_blank_choice(CableLengthUnitChoices),
|
choices=add_blank_choice(CableLengthUnitChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
unterminated = forms.NullBooleanField(
|
||||||
|
label=_('Unterminated'),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
)
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type')),
|
(_('Attributes'), ('name', 'label', 'type')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type')),
|
(_('Attributes'), ('name', 'label', 'type')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
(_('PoE'), ('poe_mode', 'poe_type')),
|
(_('PoE'), ('poe_mode', 'poe_type')),
|
||||||
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
vdc_id = DynamicModelMultipleChoiceField(
|
vdc_id = DynamicModelMultipleChoiceField(
|
||||||
@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Cable'), ('cabled', 'occupied')),
|
||||||
)
|
)
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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')),
|
(_('Cable'), ('cabled', 'occupied')),
|
||||||
)
|
)
|
||||||
type = forms.MultipleChoiceField(
|
type = forms.MultipleChoiceField(
|
||||||
@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'position')),
|
(_('Attributes'), ('name', 'label', 'position')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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)
|
tag = TagFilterField(model)
|
||||||
position = forms.CharField(
|
position = forms.CharField(
|
||||||
@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label')),
|
(_('Attributes'), ('name', 'label')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
|||||||
(None, ('q', 'filter_id', 'tag')),
|
(None, ('q', 'filter_id', 'tag')),
|
||||||
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
(_('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(
|
role_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=InventoryItemRole.objects.all(),
|
queryset=InventoryItemRole.objects.all(),
|
||||||
|
@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
|
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
|
||||||
(_('Chassis'), (
|
(_('Chassis'), (
|
||||||
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
|
||||||
|
'weight', 'weight_unit',
|
||||||
)),
|
)),
|
||||||
(_('Images'), ('front_image', 'rear_image')),
|
(_('Images'), ('front_image', 'rear_image')),
|
||||||
)
|
)
|
||||||
@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
|
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||||
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
||||||
'comments', 'tags',
|
'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'front_image': ClearableFileInput(attrs={
|
'front_image': ClearableFileInput(attrs={
|
||||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
import utilities.fields
|
import utilities.fields
|
||||||
|
from utilities.counters import update_counts
|
||||||
|
|
||||||
|
|
||||||
def recalculate_device_counts(apps, schema_editor):
|
def recalculate_device_counts(apps, schema_editor):
|
||||||
Device = apps.get_model("dcim", "Device")
|
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:
|
update_counts(Device, 'console_port_count', 'consoleports')
|
||||||
device.console_port_count = device._console_port_count
|
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||||
device.console_server_port_count = device._console_server_port_count
|
update_counts(Device, 'power_port_count', 'powerports')
|
||||||
device.power_port_count = device._power_port_count
|
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||||
device.power_outlet_count = device._power_outlet_count
|
update_counts(Device, 'interface_count', 'interfaces')
|
||||||
device.interface_count = device._interface_count
|
update_counts(Device, 'front_port_count', 'frontports')
|
||||||
device.front_port_count = device._front_port_count
|
update_counts(Device, 'rear_port_count', 'rearports')
|
||||||
device.rear_port_count = device._rear_port_count
|
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||||
device.device_bay_count = device._device_bay_count
|
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||||
device.module_bay_count = device._module_bay_count
|
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
import utilities.fields
|
import utilities.fields
|
||||||
|
from utilities.counters import update_counts
|
||||||
|
|
||||||
|
|
||||||
def recalculate_devicetype_template_counts(apps, schema_editor):
|
def recalculate_devicetype_template_counts(apps, schema_editor):
|
||||||
DeviceType = apps.get_model("dcim", "DeviceType")
|
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:
|
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||||
devicetype.console_port_template_count = devicetype._console_port_template_count
|
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||||
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
|
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||||
devicetype.power_port_template_count = devicetype._power_port_template_count
|
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||||
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
|
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||||
devicetype.interface_template_count = devicetype._interface_template_count
|
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||||
devicetype.front_port_template_count = devicetype._front_port_template_count
|
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||||
devicetype.rear_port_template_count = devicetype._rear_port_template_count
|
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||||
devicetype.device_bay_template_count = devicetype._device_bay_template_count
|
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||||
devicetype.module_bay_template_count = devicetype._module_bay_template_count
|
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||||
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',
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -2,17 +2,13 @@ from django.db import migrations
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
|
||||||
import utilities.fields
|
import utilities.fields
|
||||||
|
from utilities.counters import update_counts
|
||||||
|
|
||||||
|
|
||||||
def populate_virtualchassis_members(apps, schema_editor):
|
def populate_virtualchassis_members(apps, schema_editor):
|
||||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||||
|
|
||||||
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
|
update_counts(VirtualChassis, 'member_count', 'members')
|
||||||
|
|
||||||
for vc in vcs:
|
|
||||||
vc.member_count = vc._member_count
|
|
||||||
|
|
||||||
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.5 on 2023-10-20 22:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0181_rename_device_role_device_role'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='exclude_from_utilization',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal file
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.2.6 on 2023-10-20 11:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0182_devicetype_exclude_from_utilization'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='interface',
|
||||||
|
name='parent',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='dcim.interface'),
|
||||||
|
),
|
||||||
|
]
|
@ -20,7 +20,7 @@ from utilities.fields import ColorField
|
|||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
from utilities.utils import to_meters
|
from utilities.utils import to_meters
|
||||||
from wireless.models import WirelessLink
|
from wireless.models import WirelessLink
|
||||||
from .device_components import FrontPort, RearPort
|
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Cable',
|
'Cable',
|
||||||
@ -98,10 +98,10 @@ class Cable(PrimaryModel):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
# 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
|
# 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
|
self._terminations_modified = False
|
||||||
|
|
||||||
@ -518,9 +518,16 @@ class CablePath(models.Model):
|
|||||||
# Terminations must all be of the same type
|
# Terminations must all be of the same type
|
||||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
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
|
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||||
# different cables attached)
|
# 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
|
is_split = True
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -529,46 +536,68 @@ class CablePath(models.Model):
|
|||||||
object_to_path_node(t) for t in terminations
|
object_to_path_node(t) for t in terminations
|
||||||
])
|
])
|
||||||
|
|
||||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||||
link = terminations[0].link
|
links = [termination.link for termination in terminations if termination.link is not None]
|
||||||
if link is None and len(path) == 1:
|
if len(links) == 0:
|
||||||
|
if len(path) == 1:
|
||||||
# If this is the start of the path and no link exists, return None
|
# If this is the start of the path and no link exists, return None
|
||||||
return None
|
return None
|
||||||
elif link is None:
|
|
||||||
# Otherwise, halt the trace if no link exists
|
# Otherwise, halt the trace if no link exists
|
||||||
break
|
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"
|
# Step 3: Record asymmetric paths as split
|
||||||
path.append([object_to_path_node(link)])
|
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
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
|
is_active = False
|
||||||
|
|
||||||
# Step 4: Determine the far-end terminations
|
# Step 6: Determine the far-end terminations
|
||||||
if isinstance(link, Cable):
|
if isinstance(links[0], Cable):
|
||||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||||
local_cable_terminations = CableTermination.objects.filter(
|
local_cable_terminations = CableTermination.objects.filter(
|
||||||
termination_type=termination_type,
|
termination_type=termination_type,
|
||||||
termination_id__in=[t.pk for t in terminations]
|
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
|
q_filter = Q()
|
||||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
for lct in local_cable_terminations:
|
||||||
remote_cable_terminations = CableTermination.objects.filter(
|
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||||
cable=link,
|
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
|
||||||
)
|
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||||
else:
|
else:
|
||||||
# WirelessLink
|
# 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([
|
path.append([
|
||||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
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:
|
if not remote_terminations:
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -577,20 +606,32 @@ class CablePath(models.Model):
|
|||||||
rear_ports = RearPort.objects.filter(
|
rear_ports = RearPort.objects.filter(
|
||||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||||
)
|
)
|
||||||
if len(rear_ports) > 1:
|
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||||
assert all(rp.positions == 1 for rp in rear_ports)
|
|
||||||
elif rear_ports[0].positions > 1:
|
|
||||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||||
|
|
||||||
terminations = rear_ports
|
terminations = rear_ports
|
||||||
|
|
||||||
elif isinstance(remote_terminations[0], RearPort):
|
elif isinstance(remote_terminations[0], RearPort):
|
||||||
|
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
|
||||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
|
||||||
front_ports = FrontPort.objects.filter(
|
front_ports = FrontPort.objects.filter(
|
||||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||||
rear_port_position=1
|
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:
|
elif position_stack:
|
||||||
front_ports = FrontPort.objects.filter(
|
front_ports = FrontPort.objects.filter(
|
||||||
rear_port_id=remote_terminations[0].pk,
|
rear_port_id=remote_terminations[0].pk,
|
||||||
@ -632,9 +673,16 @@ class CablePath(models.Model):
|
|||||||
|
|
||||||
terminations = [circuit_termination]
|
terminations = [circuit_termination]
|
||||||
|
|
||||||
# Anything else marks the end of the path
|
|
||||||
else:
|
else:
|
||||||
|
# Check for non-symmetric path
|
||||||
|
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||||
is_complete = True
|
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
|
break
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@ -740,3 +788,15 @@ class CablePath(models.Model):
|
|||||||
return [
|
return [
|
||||||
ct.get_peer_termination() for ct in nodes
|
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)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Cache the original DeviceType ID for reference under clean()
|
# 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):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(action)
|
objectchange = super().to_objectchange(action)
|
||||||
@ -534,14 +534,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
|||||||
# Validate rear port assignment
|
# Validate rear port assignment
|
||||||
if self.rear_port.device_type != self.device_type:
|
if self.rear_port.device_type != self.device_type:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Rear port ({}) must belong to the same device type").format(self.rear_port)
|
_("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate rear port position assignment
|
# Validate rear port position assignment
|
||||||
if self.rear_port_position > self.rear_port.positions:
|
if self.rear_port_position > self.rear_port.positions:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("Invalid rear port position ({}); rear port {} has only {} positions").format(
|
_("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
|
||||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
position=self.rear_port_position,
|
||||||
|
name=self.rear_port.name,
|
||||||
|
count=self.rear_port.positions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Cache the original Device ID for reference under clean()
|
# 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):
|
def __str__(self):
|
||||||
if self.label:
|
if self.label:
|
||||||
@ -537,7 +537,7 @@ class BaseInterface(models.Model):
|
|||||||
)
|
)
|
||||||
parent = models.ForeignKey(
|
parent = models.ForeignKey(
|
||||||
to='self',
|
to='self',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.RESTRICT,
|
||||||
related_name='child_interfaces',
|
related_name='child_interfaces',
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -799,9 +799,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
|||||||
if self.bridge and self.bridge.device != self.device:
|
if self.bridge and self.bridge.device != self.device:
|
||||||
if self.device.virtual_chassis is None:
|
if self.device.virtual_chassis is None:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'bridge': _("""
|
'bridge': _(
|
||||||
The selected bridge interface ({bridge}) belongs to a different device
|
"The selected bridge interface ({bridge}) belongs to a different device ({device})."
|
||||||
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
|
).format(bridge=self.bridge, device=self.bridge.device)
|
||||||
})
|
})
|
||||||
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
|
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
@ -889,10 +889,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
|||||||
# Validate untagged VLAN
|
# Validate untagged VLAN
|
||||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'untagged_vlan': _("""
|
'untagged_vlan': _(
|
||||||
The untagged VLAN ({untagged_vlan}) must belong to the same site as the
|
"The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
|
||||||
interface's parent device, or it must be global.
|
"device, or it must be global."
|
||||||
""").format(untagged_vlan=self.untagged_vlan)
|
).format(untagged_vlan=self.untagged_vlan)
|
||||||
})
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@ -1067,9 +1067,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
|||||||
frontport_count = self.frontports.count()
|
frontport_count = self.frontports.count()
|
||||||
if self.positions < frontport_count:
|
if self.positions < frontport_count:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"positions": _("""
|
"positions": _(
|
||||||
The number of positions cannot be less than the number of mapped front ports
|
"The number of positions cannot be less than the number of mapped front ports "
|
||||||
({frontport_count})""").format(frontport_count=frontport_count)
|
"({frontport_count})"
|
||||||
|
).format(frontport_count=frontport_count)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import yaml
|
|||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, ProtectedError
|
from django.db.models import F, ProtectedError
|
||||||
@ -105,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
verbose_name=_('height (U)')
|
verbose_name=_('height (U)')
|
||||||
)
|
)
|
||||||
|
exclude_from_utilization = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('exclude from utilization'),
|
||||||
|
help_text=_('Exclude from rack utilization calculations.')
|
||||||
|
)
|
||||||
is_full_depth = models.BooleanField(
|
is_full_depth = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_('is full depth'),
|
verbose_name=_('is full depth'),
|
||||||
@ -205,11 +211,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Save a copy of u_height for validation in clean()
|
# 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
|
# Save references to the original front/rear images
|
||||||
self._original_front_image = self.front_image
|
self._original_front_image = self.__dict__.get('front_image')
|
||||||
self._original_rear_image = self.rear_image
|
self._original_rear_image = self.__dict__.get('rear_image')
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('dcim:devicetype', args=[self.pk])
|
return reverse('dcim:devicetype', args=[self.pk])
|
||||||
@ -296,8 +302,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
)
|
)
|
||||||
if d.position not in u_available:
|
if d.position not in u_available:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
|
'u_height': _(
|
||||||
"{}U").format(d, d.rack, self.u_height)
|
"Device {device} in rack {rack} does not have sufficient space to accommodate a "
|
||||||
|
"height of {height}U"
|
||||||
|
).format(device=d, rack=d.rack, height=self.u_height)
|
||||||
})
|
})
|
||||||
|
|
||||||
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
|
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
|
||||||
@ -332,10 +340,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
ret = super().save(*args, **kwargs)
|
ret = super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Delete any previously uploaded image files that are no longer in use
|
# Delete any previously uploaded image files that are no longer in use
|
||||||
if self.front_image != self._original_front_image:
|
if self._original_front_image and self.front_image != self._original_front_image:
|
||||||
self._original_front_image.delete(save=False)
|
default_storage.delete(self._original_front_image)
|
||||||
if self.rear_image != self._original_rear_image:
|
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||||
self._original_rear_image.delete(save=False)
|
default_storage.delete(self._original_rear_image)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@ -914,7 +922,7 @@ class Device(
|
|||||||
if self.primary_ip4:
|
if self.primary_ip4:
|
||||||
if self.primary_ip4.family != 4:
|
if self.primary_ip4.family != 4:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
|
'primary_ip4': _("{ip} is not an IPv4 address.").format(ip=self.primary_ip4)
|
||||||
})
|
})
|
||||||
if self.primary_ip4.assigned_object in vc_interfaces:
|
if self.primary_ip4.assigned_object in vc_interfaces:
|
||||||
pass
|
pass
|
||||||
@ -923,13 +931,13 @@ class Device(
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip4': _(
|
'primary_ip4': _(
|
||||||
"The specified IP address ({primary_ip4}) is not assigned to this device."
|
"The specified IP address ({ip}) is not assigned to this device."
|
||||||
).format(primary_ip4=self.primary_ip4)
|
).format(ip=self.primary_ip4)
|
||||||
})
|
})
|
||||||
if self.primary_ip6:
|
if self.primary_ip6:
|
||||||
if self.primary_ip6.family != 6:
|
if self.primary_ip6.family != 6:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
|
'primary_ip6': _("{ip} is not an IPv6 address.").format(ip=self.primary_ip6)
|
||||||
})
|
})
|
||||||
if self.primary_ip6.assigned_object in vc_interfaces:
|
if self.primary_ip6.assigned_object in vc_interfaces:
|
||||||
pass
|
pass
|
||||||
@ -938,8 +946,8 @@ class Device(
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'primary_ip6': _(
|
'primary_ip6': _(
|
||||||
"The specified IP address ({primary_ip6}) is not assigned to this device."
|
"The specified IP address ({ip}) is not assigned to this device."
|
||||||
).format(primary_ip6=self.primary_ip6)
|
).format(ip=self.primary_ip6)
|
||||||
})
|
})
|
||||||
if self.oob_ip:
|
if self.oob_ip:
|
||||||
if self.oob_ip.assigned_object in vc_interfaces:
|
if self.oob_ip.assigned_object in vc_interfaces:
|
||||||
@ -957,17 +965,19 @@ class Device(
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'platform': _(
|
'platform': _(
|
||||||
"The assigned platform is limited to {platform_manufacturer} device types, but this device's "
|
"The assigned platform is limited to {platform_manufacturer} device types, but this device's "
|
||||||
"type belongs to {device_type_manufacturer}."
|
"type belongs to {devicetype_manufacturer}."
|
||||||
).format(
|
).format(
|
||||||
platform_manufacturer=self.platform.manufacturer,
|
platform_manufacturer=self.platform.manufacturer,
|
||||||
device_type_manufacturer=self.device_type.manufacturer
|
devicetype_manufacturer=self.device_type.manufacturer
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
|
'cluster': _("The assigned cluster belongs to a different site ({site})").format(
|
||||||
|
site=self.cluster.site
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Validate virtual chassis assignment
|
# Validate virtual chassis assignment
|
||||||
@ -1439,8 +1449,8 @@ class VirtualDeviceContext(PrimaryModel):
|
|||||||
if primary_ip.family != family:
|
if primary_ip.family != family:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
f'primary_ip{family}': _(
|
f'primary_ip{family}': _(
|
||||||
"{primary_ip} is not an IPv{family} address."
|
"{ip} is not an IPv{family} address."
|
||||||
).format(family=family, primary_ip=primary_ip)
|
).format(family=family, ip=primary_ip)
|
||||||
})
|
})
|
||||||
device_interfaces = self.device.vc_interfaces(if_master=False)
|
device_interfaces = self.device.vc_interfaces(if_master=False)
|
||||||
if primary_ip.assigned_object not in device_interfaces:
|
if primary_ip.assigned_object not in device_interfaces:
|
||||||
|
@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
|
|||||||
"""
|
"""
|
||||||
if self.config_template:
|
if self.config_template:
|
||||||
return 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
|
return self.role.config_template
|
||||||
if self.platform and self.platform.config_template:
|
if self.platform and self.platform.config_template:
|
||||||
return 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
|
# Rack must belong to same Site as PowerPanel
|
||||||
if self.rack and self.rack.site != self.power_panel.site:
|
if self.rack and self.rack.site != self.power_panel.site:
|
||||||
raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
|
raise ValidationError(_(
|
||||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
"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
|
# AC voltage cannot be negative
|
||||||
|
@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
|
|
||||||
return [u for u in elevation.values()]
|
return [u for u in elevation.values()]
|
||||||
|
|
||||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
|
def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
|
||||||
"""
|
"""
|
||||||
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
||||||
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
||||||
@ -366,9 +366,13 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
:param u_height: Minimum number of contiguous free units required
|
:param u_height: Minimum number of contiguous free units required
|
||||||
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
|
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
|
||||||
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
|
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
|
||||||
|
:param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations
|
||||||
"""
|
"""
|
||||||
# Gather all devices which consume U space within the rack
|
# Gather all devices which consume U space within the rack
|
||||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
||||||
|
if ignore_excluded_devices:
|
||||||
|
devices = devices.exclude(device_type__exclude_from_utilization=True)
|
||||||
|
|
||||||
if exclude is not None:
|
if exclude is not None:
|
||||||
devices = devices.exclude(pk__in=exclude)
|
devices = devices.exclude(pk__in=exclude)
|
||||||
|
|
||||||
@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
"""
|
"""
|
||||||
# Determine unoccupied units
|
# Determine unoccupied units
|
||||||
total_units = len(list(self.units))
|
total_units = len(list(self.units))
|
||||||
available_units = self.get_available_units(u_height=0.5)
|
available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True)
|
||||||
|
|
||||||
# Remove reserved units
|
# Remove reserved units
|
||||||
for ru in self.get_reserved_units():
|
for ru in self.get_reserved_units():
|
||||||
@ -558,9 +562,9 @@ class RackReservation(PrimaryModel):
|
|||||||
invalid_units = [u for u in self.units if u not in self.rack.units]
|
invalid_units = [u for u in self.units if u not in self.rack.units]
|
||||||
if invalid_units:
|
if invalid_units:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'units': _("Invalid unit(s) for {}U rack: {}").format(
|
'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format(
|
||||||
self.rack.u_height,
|
height=self.rack.u_height,
|
||||||
', '.join([str(u) for u in invalid_units]),
|
unit_list=', '.join([str(u) for u in invalid_units])
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -571,8 +575,8 @@ class RackReservation(PrimaryModel):
|
|||||||
conflicting_units = [u for u in self.units if u in reserved_units]
|
conflicting_units = [u for u in self.units if u in reserved_units]
|
||||||
if conflicting_units:
|
if conflicting_units:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'units': _('The following units have already been reserved: {}').format(
|
'units': _('The following units have already been reserved: {unit_list}').format(
|
||||||
', '.join([str(u) for u in conflicting_units]),
|
unit_list=', '.join([str(u) for u in conflicting_units])
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -32,11 +32,18 @@ class Node(Hyperlink):
|
|||||||
color: Box fill color (RRGGBB format)
|
color: Box fill color (RRGGBB format)
|
||||||
labels: An iterable of text strings. Each label will render on a new line within the box.
|
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)
|
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)
|
super(Node, self).__init__(href=url, target='_parent', **extra)
|
||||||
|
|
||||||
|
# Save object for reference by cable systems
|
||||||
|
self.object = object
|
||||||
|
|
||||||
x, y = position
|
x, y = position
|
||||||
|
|
||||||
# Add the box
|
# Add the box
|
||||||
@ -77,7 +84,7 @@ class Connector(Group):
|
|||||||
labels: Iterable of text labels
|
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)
|
super().__init__(class_='connector', **extra)
|
||||||
|
|
||||||
self.start = start
|
self.start = start
|
||||||
@ -104,6 +111,8 @@ class Connector(Group):
|
|||||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||||
link.add(text)
|
link.add(text)
|
||||||
|
if len(description) > 0:
|
||||||
|
link.set_desc("\n".join(description))
|
||||||
|
|
||||||
self.add(link)
|
self.add(link)
|
||||||
|
|
||||||
@ -150,7 +159,10 @@ class CableTraceSVG:
|
|||||||
labels.append(location_label)
|
labels.append(location_label)
|
||||||
elif instance._meta.model_name == 'circuit':
|
elif instance._meta.model_name == 'circuit':
|
||||||
labels[0] = f'Circuit {instance}'
|
labels[0] = f'Circuit {instance}'
|
||||||
|
labels.append(instance.type)
|
||||||
labels.append(instance.provider)
|
labels.append(instance.provider)
|
||||||
|
if instance.description:
|
||||||
|
labels.append(instance.description)
|
||||||
elif instance._meta.model_name == 'circuittermination':
|
elif instance._meta.model_name == 'circuittermination':
|
||||||
if instance.xconnect_id:
|
if instance.xconnect_id:
|
||||||
labels.append(f'{instance.xconnect_id}')
|
labels.append(f'{instance.xconnect_id}')
|
||||||
@ -170,6 +182,8 @@ class CableTraceSVG:
|
|||||||
if hasattr(instance, 'role'):
|
if hasattr(instance, 'role'):
|
||||||
# Device
|
# Device
|
||||||
return instance.role.color
|
return instance.role.color
|
||||||
|
elif instance._meta.model_name == 'circuit' and instance.type.color:
|
||||||
|
return instance.type.color
|
||||||
else:
|
else:
|
||||||
# Other parent object
|
# Other parent object
|
||||||
return 'e0e0e0'
|
return 'e0e0e0'
|
||||||
@ -206,7 +220,8 @@ class CableTraceSVG:
|
|||||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||||
color=self._get_color(term),
|
color=self._get_color(term),
|
||||||
labels=self._get_labels(term),
|
labels=self._get_labels(term),
|
||||||
radius=5
|
radius=5,
|
||||||
|
object=term
|
||||||
)
|
)
|
||||||
nodes_height = max(nodes_height, node.box['height'])
|
nodes_height = max(nodes_height, node.box['height'])
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
@ -238,22 +253,65 @@ class CableTraceSVG:
|
|||||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||||
))
|
))
|
||||||
|
|
||||||
def draw_cable(self, cable):
|
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 = [
|
labels = [
|
||||||
f'Cable {cable}',
|
f'Cable {cable}',
|
||||||
cable.get_status_display()
|
cable.get_status_display()
|
||||||
]
|
]
|
||||||
|
description = []
|
||||||
if cable.type:
|
if cable.type:
|
||||||
labels.append(cable.get_type_display())
|
labels.append(cable.get_type_display())
|
||||||
if cable.length and cable.length_unit:
|
if cable.length and cable.length_unit:
|
||||||
|
# Include the cable length in the tooltip
|
||||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
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(
|
connector = Connector(
|
||||||
start=(self.center + OFFSET, self.cursor),
|
start=(center, self.cursor),
|
||||||
color=cable.color or '000000',
|
color=cable.color or '000000',
|
||||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||||
labels=labels
|
labels=labels,
|
||||||
|
description=description
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set the cursor position
|
||||||
self.cursor += connector.height
|
self.cursor += connector.height
|
||||||
|
|
||||||
return connector
|
return connector
|
||||||
@ -334,21 +392,35 @@ class CableTraceSVG:
|
|||||||
|
|
||||||
# Connector (a Cable or WirelessLink)
|
# Connector (a Cable or WirelessLink)
|
||||||
if links:
|
if links:
|
||||||
link = links[0] # Remove Cable from list
|
link_cables = {}
|
||||||
|
fanin = False
|
||||||
|
fanout = False
|
||||||
|
|
||||||
# Cable
|
# Determine if we have fanins or fanouts
|
||||||
if type(link) is Cable:
|
if len(near_ends) > len(set(links)):
|
||||||
|
|
||||||
# Account for fan-ins height
|
|
||||||
if len(near_ends) > 1:
|
|
||||||
self.cursor += FANOUT_HEIGHT
|
self.cursor += FANOUT_HEIGHT
|
||||||
|
fanin = True
|
||||||
cable = self.draw_cable(link)
|
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)
|
self.connectors.append(cable)
|
||||||
|
|
||||||
# Draw fan-ins
|
# Draw fan-ins
|
||||||
if len(near_ends) > 1:
|
if len(near_ends) > 1 and fanin:
|
||||||
for term in terminations:
|
for term in terminations:
|
||||||
|
if term.object.cable == link:
|
||||||
self.draw_fanin(term, cable)
|
self.draw_fanin(term, cable)
|
||||||
|
|
||||||
# WirelessLink
|
# WirelessLink
|
||||||
@ -358,10 +430,14 @@ class CableTraceSVG:
|
|||||||
|
|
||||||
# Far end termination(s)
|
# Far end termination(s)
|
||||||
if len(far_ends) > 1:
|
if len(far_ends) > 1:
|
||||||
|
if fanout:
|
||||||
self.cursor += FANOUT_HEIGHT
|
self.cursor += FANOUT_HEIGHT
|
||||||
terminations = self.draw_terminations(far_ends)
|
terminations = self.draw_terminations(far_ends)
|
||||||
for term in terminations:
|
for term in terminations:
|
||||||
self.draw_fanout(term, cable)
|
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:
|
elif far_ends:
|
||||||
self.draw_terminations(far_ends)
|
self.draw_terminations(far_ends)
|
||||||
else:
|
else:
|
||||||
|
@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
|
|||||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||||
"""
|
"""
|
||||||
if record.enabled:
|
if record.enabled:
|
||||||
return "enabled"
|
return 'enabled'
|
||||||
else:
|
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-name': lambda record: record.name,
|
||||||
'data-enabled': get_interface_state_attribute,
|
'data-enabled': get_interface_state_attribute,
|
||||||
'data-type': lambda record: record.type,
|
'data-type': lambda record: record.type,
|
||||||
|
'data-connected': get_interface_connected_attribute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -871,8 +882,9 @@ class ModuleBayTable(DeviceComponentTable):
|
|||||||
url_name='dcim:modulebay_list'
|
url_name='dcim:modulebay_list'
|
||||||
)
|
)
|
||||||
module_status = columns.TemplateColumn(
|
module_status = columns.TemplateColumn(
|
||||||
verbose_name=_('Module Status'),
|
accessor=tables.A('installed_module__status'),
|
||||||
template_code=MODULEBAY_STATUS
|
template_code=MODULEBAY_STATUS,
|
||||||
|
verbose_name=_('Module Status')
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(DeviceComponentTable.Meta):
|
||||||
|
@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
verbose_name=_('U Height'),
|
verbose_name=_('U Height'),
|
||||||
template_code='{{ value|floatformat }}'
|
template_code='{{ value|floatformat }}'
|
||||||
)
|
)
|
||||||
|
exclude_from_utilization = columns.BooleanColumn()
|
||||||
weight = columns.TemplateColumn(
|
weight = columns.TemplateColumn(
|
||||||
verbose_name=_('Weight'),
|
verbose_name=_('Weight'),
|
||||||
template_code=WEIGHT,
|
template_code=WEIGHT,
|
||||||
@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = models.DeviceType
|
model = models.DeviceType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
|
||||||
'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created',
|
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||||
'last_updated',
|
'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||||
|
@ -1607,6 +1607,33 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_bulk_delete_child_interfaces(self):
|
||||||
|
interface1 = Interface.objects.get(name='Interface 1')
|
||||||
|
device = interface1.device
|
||||||
|
self.add_permissions('dcim.delete_interface')
|
||||||
|
|
||||||
|
# Create a child interface
|
||||||
|
child = Interface.objects.create(
|
||||||
|
device=device,
|
||||||
|
name='Interface 1A',
|
||||||
|
type=InterfaceTypeChoices.TYPE_VIRTUAL,
|
||||||
|
parent=interface1
|
||||||
|
)
|
||||||
|
self.assertEqual(device.interfaces.count(), 4)
|
||||||
|
|
||||||
|
# Attempt to delete only the parent interface
|
||||||
|
url = self._get_detail_url(interface1)
|
||||||
|
self.client.delete(url, **self.header)
|
||||||
|
self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted
|
||||||
|
|
||||||
|
# Attempt to bulk delete parent & child together
|
||||||
|
data = [
|
||||||
|
{"id": interface1.pk},
|
||||||
|
{"id": child.pk},
|
||||||
|
]
|
||||||
|
self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
||||||
|
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
|
@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
|
|||||||
1XX: Test direct connections between different endpoint types
|
1XX: Test direct connections between different endpoint types
|
||||||
2XX: Test different cable topologies
|
2XX: Test different cable topologies
|
||||||
3XX: Test responses to changes in existing objects
|
3XX: Test responses to changes in existing objects
|
||||||
|
4XX: Test to exclude specific cable topologies
|
||||||
"""
|
"""
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
|
|||||||
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
||||||
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
|
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 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)
|
:return: The matching CablePath (if any)
|
||||||
"""
|
"""
|
||||||
@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
|
|||||||
path.append([object_to_path_node(node) for node in step])
|
path.append([object_to_path_node(node) for node in step])
|
||||||
else:
|
else:
|
||||||
path.append([object_to_path_node(step)])
|
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')
|
self.assertIsNotNone(cablepath, msg='CablePath not found')
|
||||||
|
|
||||||
return cablepath
|
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):
|
def assertPathIsSet(self, origin, cablepath, msg=None):
|
||||||
"""
|
"""
|
||||||
Assert that a specific CablePath instance is set as the path on the origin.
|
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(interface3, path3)
|
||||||
self.assertPathIsSet(interface4, path4)
|
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):
|
def test_301_create_path_via_existing_cable(self):
|
||||||
"""
|
"""
|
||||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||||
@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
|
|||||||
is_complete=True,
|
is_complete=True,
|
||||||
is_active=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[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 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||||
Interface(device=devices[5], name='Interface 12', 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)
|
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=[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(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):
|
def test_label(self):
|
||||||
params = {'label': ['Cable 1', 'Cable 2']}
|
params = {'label': ['Cable 1', 'Cable 2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)
|
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):
|
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = PowerPanel.objects.all()
|
queryset = PowerPanel.objects.all()
|
||||||
|
@ -238,6 +238,40 @@ class RackTestCase(TestCase):
|
|||||||
# Check that Device1 is now assigned to Site B
|
# Check that Device1 is now assigned to Site B
|
||||||
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
|
self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b)
|
||||||
|
|
||||||
|
def test_utilization(self):
|
||||||
|
site = Site.objects.first()
|
||||||
|
rack = Rack.objects.first()
|
||||||
|
|
||||||
|
Device(
|
||||||
|
name='Device 1',
|
||||||
|
role=DeviceRole.objects.first(),
|
||||||
|
device_type=DeviceType.objects.first(),
|
||||||
|
site=site,
|
||||||
|
rack=rack,
|
||||||
|
position=1
|
||||||
|
).save()
|
||||||
|
rack.refresh_from_db()
|
||||||
|
self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
|
||||||
|
|
||||||
|
# create device excluded from utilization calculations
|
||||||
|
dt = DeviceType.objects.create(
|
||||||
|
manufacturer=Manufacturer.objects.first(),
|
||||||
|
model='Device Type 4',
|
||||||
|
slug='device-type-4',
|
||||||
|
u_height=1,
|
||||||
|
exclude_from_utilization=True
|
||||||
|
)
|
||||||
|
Device(
|
||||||
|
name='Device 2',
|
||||||
|
role=DeviceRole.objects.first(),
|
||||||
|
device_type=dt,
|
||||||
|
site=site,
|
||||||
|
rack=rack,
|
||||||
|
position=5
|
||||||
|
).save()
|
||||||
|
rack.refresh_from_db()
|
||||||
|
self.assertEqual(rack.get_utilization(), 1 / 42 * 100)
|
||||||
|
|
||||||
|
|
||||||
class DeviceTestCase(TestCase):
|
class DeviceTestCase(TestCase):
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ from dcim.constants import *
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from ipam.models import ASN, RIR, VLAN, VRF
|
from ipam.models import ASN, RIR, VLAN, VRF
|
||||||
from tenancy.models import Tenant
|
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 utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||||
from wireless.models import WirelessLAN
|
from wireless.models import WirelessLAN
|
||||||
|
|
||||||
@ -2014,6 +2014,7 @@ class ModuleTestCase(
|
|||||||
'data': {
|
'data': {
|
||||||
'data': '\n'.join(csv_data),
|
'data': '\n'.join(csv_data),
|
||||||
'format': ImportFormatChoices.CSV,
|
'format': ImportFormatChoices.CSV,
|
||||||
|
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2030,6 +2031,7 @@ class ModuleTestCase(
|
|||||||
'data': {
|
'data': {
|
||||||
'data': '\n'.join(csv_data),
|
'data': '\n'.join(csv_data),
|
||||||
'format': ImportFormatChoices.CSV,
|
'format': ImportFormatChoices.CSV,
|
||||||
|
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2106,6 +2108,7 @@ class ModuleTestCase(
|
|||||||
'data': {
|
'data': {
|
||||||
'data': '\n'.join(csv_data),
|
'data': '\n'.join(csv_data),
|
||||||
'format': ImportFormatChoices.CSV,
|
'format': ImportFormatChoices.CSV,
|
||||||
|
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2528,6 +2531,36 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
|||||||
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
|
response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk}))
|
||||||
self.assertHttpStatus(response, 200)
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
def test_bulk_delete_child_interfaces(self):
|
||||||
|
interface1 = Interface.objects.get(name='Interface 1')
|
||||||
|
device = interface1.device
|
||||||
|
self.add_permissions('dcim.delete_interface')
|
||||||
|
|
||||||
|
# Create a child interface
|
||||||
|
child = Interface.objects.create(
|
||||||
|
device=device,
|
||||||
|
name='Interface 1A',
|
||||||
|
type=InterfaceTypeChoices.TYPE_VIRTUAL,
|
||||||
|
parent=interface1
|
||||||
|
)
|
||||||
|
self.assertEqual(device.interfaces.count(), 6)
|
||||||
|
|
||||||
|
# Attempt to delete only the parent interface
|
||||||
|
data = {
|
||||||
|
'confirm': True,
|
||||||
|
}
|
||||||
|
self.client.post(self._get_url('delete', interface1), data)
|
||||||
|
self.assertEqual(device.interfaces.count(), 6) # Parent was not deleted
|
||||||
|
|
||||||
|
# Attempt to bulk delete parent & child together
|
||||||
|
data = {
|
||||||
|
'pk': [interface1.pk, child.pk],
|
||||||
|
'confirm': True,
|
||||||
|
'_confirm': True, # Form button
|
||||||
|
}
|
||||||
|
self.client.post(self._get_url('bulk_delete'), data)
|
||||||
|
self.assertEqual(device.interfaces.count(), 4) # Child & parent were both deleted
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||||
model = FrontPort
|
model = FrontPort
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import traceback
|
import traceback
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -20,11 +19,13 @@ from circuits.models import Circuit, CircuitTermination
|
|||||||
from extras.views import ObjectConfigContextView
|
from extras.views import ObjectConfigContextView
|
||||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||||
from ipam.tables import InterfaceVLANTable
|
from ipam.tables import InterfaceVLANTable
|
||||||
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
@ -46,15 +47,11 @@ CABLE_TERMINATION_TYPES = {
|
|||||||
|
|
||||||
|
|
||||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
'bulk_disconnect': {'change'},
|
'bulk_disconnect': {'change'},
|
||||||
})
|
}
|
||||||
queryset = Device.objects.all()
|
queryset = Device.objects.all()
|
||||||
|
|
||||||
def get_children(self, request, parent):
|
def get_children(self, request, parent):
|
||||||
@ -122,16 +119,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
cable_ids = set()
|
||||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||||
if obj.cable is None:
|
if obj.cable:
|
||||||
continue
|
cable_ids.add(obj.cable.pk)
|
||||||
obj.cable.delete()
|
|
||||||
count += 1
|
count += 1
|
||||||
|
for cable in Cable.objects.filter(pk__in=cable_ids):
|
||||||
|
cable.delete()
|
||||||
|
|
||||||
messages.success(request, "Disconnected {} {}".format(
|
messages.success(request, _("Disconnected {count} {type}").format(
|
||||||
count, self.queryset.model._meta.verbose_name_plural
|
count=count,
|
||||||
|
type=self.queryset.model._meta.verbose_name_plural
|
||||||
))
|
))
|
||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
@ -1975,7 +1974,10 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
|||||||
table = tables.DeviceModuleBayTable
|
table = tables.DeviceModuleBayTable
|
||||||
filterset = filtersets.ModuleBayFilterSet
|
filterset = filtersets.ModuleBayFilterSet
|
||||||
template_name = 'dcim/device/modulebays.html'
|
template_name = 'dcim/device/modulebays.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
|
'bulk_rename': {'change'},
|
||||||
|
}
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Module Bays'),
|
label=_('Module Bays'),
|
||||||
badge=lambda obj: obj.module_bay_count,
|
badge=lambda obj: obj.module_bay_count,
|
||||||
@ -1991,7 +1993,10 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
|||||||
table = tables.DeviceDeviceBayTable
|
table = tables.DeviceDeviceBayTable
|
||||||
filterset = filtersets.DeviceBayFilterSet
|
filterset = filtersets.DeviceBayFilterSet
|
||||||
template_name = 'dcim/device/devicebays.html'
|
template_name = 'dcim/device/devicebays.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
|
'bulk_rename': {'change'},
|
||||||
|
}
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Device Bays'),
|
label=_('Device Bays'),
|
||||||
badge=lambda obj: obj.device_bay_count,
|
badge=lambda obj: obj.device_bay_count,
|
||||||
@ -2003,11 +2008,14 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
|||||||
|
|
||||||
@register_model_view(Device, 'inventory')
|
@register_model_view(Device, 'inventory')
|
||||||
class DeviceInventoryView(DeviceComponentsView):
|
class DeviceInventoryView(DeviceComponentsView):
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
|
||||||
child_model = InventoryItem
|
child_model = InventoryItem
|
||||||
table = tables.DeviceInventoryItemTable
|
table = tables.DeviceInventoryItemTable
|
||||||
filterset = filtersets.InventoryItemFilterSet
|
filterset = filtersets.InventoryItemFilterSet
|
||||||
template_name = 'dcim/device/inventory.html'
|
template_name = 'dcim/device/inventory.html'
|
||||||
|
actions = {
|
||||||
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
|
'bulk_rename': {'change'},
|
||||||
|
}
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Inventory Items'),
|
label=_('Inventory Items'),
|
||||||
badge=lambda obj: obj.inventory_item_count,
|
badge=lambda obj: obj.inventory_item_count,
|
||||||
@ -2185,14 +2193,10 @@ class ConsolePortListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.ConsolePortFilterForm
|
filterset_form = forms.ConsolePortFilterForm
|
||||||
table = tables.ConsolePortTable
|
table = tables.ConsolePortTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConsolePort)
|
@register_model_view(ConsolePort)
|
||||||
@ -2257,14 +2261,10 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.ConsoleServerPortFilterForm
|
filterset_form = forms.ConsoleServerPortFilterForm
|
||||||
table = tables.ConsoleServerPortTable
|
table = tables.ConsoleServerPortTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConsoleServerPort)
|
@register_model_view(ConsoleServerPort)
|
||||||
@ -2329,14 +2329,10 @@ class PowerPortListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.PowerPortFilterForm
|
filterset_form = forms.PowerPortFilterForm
|
||||||
table = tables.PowerPortTable
|
table = tables.PowerPortTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(PowerPort)
|
@register_model_view(PowerPort)
|
||||||
@ -2401,14 +2397,10 @@ class PowerOutletListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.PowerOutletFilterForm
|
filterset_form = forms.PowerOutletFilterForm
|
||||||
table = tables.PowerOutletTable
|
table = tables.PowerOutletTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(PowerOutlet)
|
@register_model_view(PowerOutlet)
|
||||||
@ -2473,14 +2465,10 @@ class InterfaceListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.InterfaceFilterForm
|
filterset_form = forms.InterfaceFilterForm
|
||||||
table = tables.InterfaceTable
|
table = tables.InterfaceTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Interface)
|
@register_model_view(Interface)
|
||||||
@ -2574,7 +2562,8 @@ class InterfaceBulkDisconnectView(BulkDisconnectView):
|
|||||||
|
|
||||||
|
|
||||||
class InterfaceBulkDeleteView(generic.BulkDeleteView):
|
class InterfaceBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = Interface.objects.all()
|
# Ensure child interfaces are deleted prior to their parents
|
||||||
|
queryset = Interface.objects.order_by('device', 'parent', CollateAsChar('_name'))
|
||||||
filterset = filtersets.InterfaceFilterSet
|
filterset = filtersets.InterfaceFilterSet
|
||||||
table = tables.InterfaceTable
|
table = tables.InterfaceTable
|
||||||
|
|
||||||
@ -2593,14 +2582,10 @@ class FrontPortListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.FrontPortFilterForm
|
filterset_form = forms.FrontPortFilterForm
|
||||||
table = tables.FrontPortTable
|
table = tables.FrontPortTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(FrontPort)
|
@register_model_view(FrontPort)
|
||||||
@ -2665,14 +2650,10 @@ class RearPortListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.RearPortFilterForm
|
filterset_form = forms.RearPortFilterForm
|
||||||
table = tables.RearPortTable
|
table = tables.RearPortTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(RearPort)
|
@register_model_view(RearPort)
|
||||||
@ -2737,14 +2718,10 @@ class ModuleBayListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.ModuleBayFilterForm
|
filterset_form = forms.ModuleBayFilterForm
|
||||||
table = tables.ModuleBayTable
|
table = tables.ModuleBayTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ModuleBay)
|
@register_model_view(ModuleBay)
|
||||||
@ -2801,14 +2778,10 @@ class DeviceBayListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.DeviceBayFilterForm
|
filterset_form = forms.DeviceBayFilterForm
|
||||||
table = tables.DeviceBayTable
|
table = tables.DeviceBayTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DeviceBay)
|
@register_model_view(DeviceBay)
|
||||||
@ -2934,14 +2907,10 @@ class InventoryItemListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.InventoryItemFilterForm
|
filterset_form = forms.InventoryItemFilterForm
|
||||||
table = tables.InventoryItemTable
|
table = tables.InventoryItemTable
|
||||||
template_name = 'dcim/component_list.html'
|
template_name = 'dcim/component_list.html'
|
||||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
actions = {
|
||||||
action_perms = defaultdict(set, **{
|
**DEFAULT_ACTION_PERMISSIONS,
|
||||||
'add': {'add'},
|
|
||||||
'import': {'add'},
|
|
||||||
'bulk_edit': {'change'},
|
|
||||||
'bulk_delete': {'delete'},
|
|
||||||
'bulk_rename': {'change'},
|
'bulk_rename': {'change'},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(InventoryItem)
|
@register_model_view(InventoryItem)
|
||||||
@ -3173,7 +3142,12 @@ class CableListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.CableFilterSet
|
filterset = filtersets.CableFilterSet
|
||||||
filterset_form = forms.CableFilterForm
|
filterset_form = forms.CableFilterForm
|
||||||
table = tables.CableTable
|
table = tables.CableTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
actions = {
|
||||||
|
'import': {'add'},
|
||||||
|
'export': {'view'},
|
||||||
|
'bulk_edit': {'change'},
|
||||||
|
'bulk_delete': {'delete'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Cable)
|
@register_model_view(Cable)
|
||||||
@ -3267,7 +3241,9 @@ class ConsoleConnectionsListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.ConsoleConnectionFilterForm
|
filterset_form = forms.ConsoleConnectionFilterForm
|
||||||
table = tables.ConsoleConnectionTable
|
table = tables.ConsoleConnectionTable
|
||||||
template_name = 'dcim/connections_list.html'
|
template_name = 'dcim/connections_list.html'
|
||||||
actions = ('export',)
|
actions = {
|
||||||
|
'export': {'view'},
|
||||||
|
}
|
||||||
|
|
||||||
def get_extra_context(self, request):
|
def get_extra_context(self, request):
|
||||||
return {
|
return {
|
||||||
@ -3281,7 +3257,9 @@ class PowerConnectionsListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.PowerConnectionFilterForm
|
filterset_form = forms.PowerConnectionFilterForm
|
||||||
table = tables.PowerConnectionTable
|
table = tables.PowerConnectionTable
|
||||||
template_name = 'dcim/connections_list.html'
|
template_name = 'dcim/connections_list.html'
|
||||||
actions = ('export',)
|
actions = {
|
||||||
|
'export': {'view'},
|
||||||
|
}
|
||||||
|
|
||||||
def get_extra_context(self, request):
|
def get_extra_context(self, request):
|
||||||
return {
|
return {
|
||||||
@ -3295,7 +3273,9 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
|||||||
filterset_form = forms.InterfaceConnectionFilterForm
|
filterset_form = forms.InterfaceConnectionFilterForm
|
||||||
table = tables.InterfaceConnectionTable
|
table = tables.InterfaceConnectionTable
|
||||||
template_name = 'dcim/connections_list.html'
|
template_name = 'dcim/connections_list.html'
|
||||||
actions = ('export',)
|
actions = {
|
||||||
|
'export': {'view'},
|
||||||
|
}
|
||||||
|
|
||||||
def get_extra_context(self, request):
|
def get_extra_context(self, request):
|
||||||
return {
|
return {
|
||||||
|
@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
data_file = NestedDataFileSerializer(
|
data_file = NestedDataFileSerializer(
|
||||||
read_only=True
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -82,6 +82,9 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
|||||||
data = [
|
data = [
|
||||||
{'id': c[0], 'display': c[1]} for c in page
|
{'id': c[0], 'display': c[1]} for c in page
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
data = []
|
||||||
|
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
|
|||||||
(ACTION_UPDATE, _('Update'), 'blue'),
|
(ACTION_UPDATE, _('Update'), 'blue'),
|
||||||
(ACTION_DELETE, _('Delete'), 'red'),
|
(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.urls import reverse_lazy
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from extras.choices import DashboardWidgetColorChoices
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||||
from utilities.choices import ButtonColorChoices
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DashboardWidgetAddForm',
|
'DashboardWidgetAddForm',
|
||||||
@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
color = forms.ChoiceField(
|
color = forms.ChoiceField(
|
||||||
choices=add_blank_choice(ButtonColorChoices),
|
choices=add_blank_choice(DashboardWidgetColorChoices),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from extras.choices import BookmarkOrderingChoices
|
from extras.choices import BookmarkOrderingChoices
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
|
from utilities.choices import ButtonColorChoices
|
||||||
from utilities.forms import BootstrapMixin
|
from utilities.forms import BootstrapMixin
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.templatetags.builtins.filters import render_markdown
|
from utilities.templatetags.builtins.filters import render_markdown
|
||||||
@ -115,6 +116,22 @@ class DashboardWidget:
|
|||||||
def name(self):
|
def name(self):
|
||||||
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
|
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
|
@property
|
||||||
def form_data(self):
|
def form_data(self):
|
||||||
return {
|
return {
|
||||||
|
@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
|
|||||||
model = Tag
|
model = Tag
|
||||||
fields = ('name', 'slug', 'color', 'description')
|
fields = ('name', 'slug', 'color', 'description')
|
||||||
help_texts = {
|
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__ = (
|
__all__ = (
|
||||||
'CustomFieldsMixin',
|
'CustomFieldsMixin',
|
||||||
'SavedFiltersMixin',
|
'SavedFiltersMixin',
|
||||||
|
'TagsMixin',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
|
|||||||
'usable': True,
|
'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.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.forms.mixins import SyncedDataMixin
|
from core.forms.mixins import SyncedDataMixin
|
||||||
@ -75,13 +76,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
'type': _(
|
'type': _(
|
||||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||||
"type below."
|
"type below."
|
||||||
)
|
),
|
||||||
|
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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:
|
if self.instance.pk:
|
||||||
self.fields['type'].disabled = True
|
self.fields['type'].disabled = True
|
||||||
|
|
||||||
@ -90,10 +93,10 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
|||||||
extra_choices = forms.CharField(
|
extra_choices = forms.CharField(
|
||||||
widget=ChoicesWidget(),
|
widget=ChoicesWidget(),
|
||||||
required=False,
|
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 '
|
'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:
|
class Meta:
|
||||||
@ -325,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
tenant_groups = DynamicModelMultipleChoiceField(
|
tenant_groups = DynamicModelMultipleChoiceField(
|
||||||
label=_('Tenat groups'),
|
label=_('Tenant groups'),
|
||||||
queryset=TenantGroup.objects.all(),
|
queryset=TenantGroup.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
@ -488,7 +491,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
|||||||
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
(_('Security'), ('ALLOWED_URL_SCHEMES',)),
|
||||||
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
(_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')),
|
||||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||||
(_('Validation'), ('CUSTOM_VALIDATORS',)),
|
(_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')),
|
||||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||||
(_('Miscellaneous'), (
|
(_('Miscellaneous'), (
|
||||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||||
@ -505,6 +508,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
|||||||
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
|
'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||||
'comment': forms.Textarea(),
|
'comment': forms.Textarea(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -515,22 +519,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
|||||||
config = get_config()
|
config = get_config()
|
||||||
for param in PARAMS:
|
for param in PARAMS:
|
||||||
value = getattr(config, param.name)
|
value = getattr(config, param.name)
|
||||||
is_static = hasattr(settings, param.name)
|
|
||||||
if value:
|
# 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):
|
||||||
|
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
|
help_text = self.fields[param.name].help_text
|
||||||
if help_text:
|
if help_text:
|
||||||
help_text += '<br />' # Line break
|
help_text += '<br />' # Line break
|
||||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
|
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||||
if is_static:
|
if value == param.default:
|
||||||
help_text += _(' (defined statically)')
|
|
||||||
elif value == param.default:
|
|
||||||
help_text += _(' (default)')
|
help_text += _(' (default)')
|
||||||
self.fields[param.name].help_text = help_text
|
self.fields[param.name].help_text = help_text
|
||||||
if type(value) in (tuple, list):
|
|
||||||
value = ', '.join(value)
|
|
||||||
self.fields[param.name].initial = value
|
|
||||||
if is_static:
|
|
||||||
self.fields[param.name].disabled = True
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
instance = super().save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
@ -59,7 +59,7 @@ class Command(BaseCommand):
|
|||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
clear_webhooks.send(request)
|
clear_webhooks.send(request)
|
||||||
job.data = ScriptOutputSerializer(script).data
|
job.data = ScriptOutputSerializer(script).data
|
||||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
|
||||||
|
|
||||||
logger.info(f"Script completed in {job.duration}")
|
logger.info(f"Script completed in {job.duration}")
|
||||||
|
|
||||||
|
17
netbox/extras/migrations/0099_cachedvalue_ordering.py
Normal file
17
netbox/extras/migrations/0099_cachedvalue_ordering.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.6 on 2023-10-30 14:04
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0098_webhook_custom_field_data_webhook_tags'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='cachedvalue',
|
||||||
|
options={'ordering': ('weight', 'object_type', 'value', 'object_id')},
|
||||||
|
),
|
||||||
|
]
|
@ -146,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
|||||||
# Verify that JSON data is provided as an object
|
# Verify that JSON data is provided as an object
|
||||||
if type(self.data) is not dict:
|
if type(self.data) is not dict:
|
||||||
raise ValidationError(
|
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):
|
def sync_data(self):
|
||||||
@ -202,7 +202,7 @@ class ConfigContextModel(models.Model):
|
|||||||
# Verify that JSON data is provided as an object
|
# Verify that JSON data is provided as an object
|
||||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||||
raise ValidationError(
|
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.utils import add_blank_choice
|
||||||
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
||||||
from utilities.querysets import RestrictedQuerySet
|
from utilities.querysets import RestrictedQuerySet
|
||||||
|
from utilities.templatetags.builtins.filters import render_markdown
|
||||||
from utilities.validators import validate_regex
|
from utilities.validators import validate_regex
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# Cache instance's original name so we can check later whether it has changed
|
# 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
|
@property
|
||||||
def search_type(self):
|
def search_type(self):
|
||||||
@ -231,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
return self.choice_set.choices
|
return self.choice_set.choices
|
||||||
return []
|
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):
|
def populate_initial_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||||
@ -281,8 +287,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
except ValidationError as err:
|
except ValidationError as err:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'default': _(
|
'default': _(
|
||||||
'Invalid default value "{default}": {message}'
|
'Invalid default value "{value}": {error}'
|
||||||
).format(default=self.default, message=err.message)
|
).format(value=self.default, error=err.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Minimum/maximum values can be set only for numeric fields
|
# Minimum/maximum values can be set only for numeric fields
|
||||||
@ -326,8 +332,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
elif self.object_type:
|
elif self.object_type:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'object_type': _(
|
'object_type': _(
|
||||||
"{type_display} fields may not define an object type.")
|
"{type} fields may not define an object type.")
|
||||||
.format(type_display=self.get_type_display())
|
.format(type=self.get_type_display())
|
||||||
})
|
})
|
||||||
|
|
||||||
def serialize(self, value):
|
def serialize(self, value):
|
||||||
@ -498,7 +504,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
field.model = self
|
field.model = self
|
||||||
field.label = str(self)
|
field.label = str(self)
|
||||||
if self.description:
|
if self.description:
|
||||||
field.help_text = escape(self.description)
|
field.help_text = render_markdown(self.description)
|
||||||
|
|
||||||
# Annotate read-only fields
|
# Annotate read-only fields
|
||||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||||
|
@ -50,7 +50,7 @@ class CachedValue(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('weight', 'object_type', 'object_id')
|
ordering = ('weight', 'object_type', 'value', 'object_id')
|
||||||
verbose_name = _('cached value')
|
verbose_name = _('cached value')
|
||||||
verbose_name_plural = _('cached values')
|
verbose_name_plural = _('cached values')
|
||||||
|
|
||||||
|
@ -1,148 +1,9 @@
|
|||||||
import collections
|
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
from packaging import version
|
|
||||||
|
|
||||||
from netbox.registry import registry
|
|
||||||
from netbox.search import register_search
|
|
||||||
from .navigation import *
|
from .navigation import *
|
||||||
from .registration import *
|
from .registration import *
|
||||||
from .templates import *
|
from .templates import *
|
||||||
from .utils import *
|
from .utils import *
|
||||||
|
from netbox.plugins import PluginConfig
|
||||||
# Initialize plugin registry
|
|
||||||
registry['plugins'].update({
|
|
||||||
'graphql_schemas': [],
|
|
||||||
'menus': [],
|
|
||||||
'menu_items': {},
|
|
||||||
'preferences': {},
|
|
||||||
'template_extensions': collections.defaultdict(list),
|
|
||||||
})
|
|
||||||
|
|
||||||
DEFAULT_RESOURCE_PATHS = {
|
|
||||||
'search_indexes': 'search.indexes',
|
|
||||||
'graphql_schema': 'graphql.schema',
|
|
||||||
'menu': 'navigation.menu',
|
|
||||||
'menu_items': 'navigation.menu_items',
|
|
||||||
'template_extensions': 'template_content.template_extensions',
|
|
||||||
'user_preferences': 'preferences.preferences',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
# TODO: Remove in v4.0
|
||||||
# Plugin AppConfig class
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
#
|
|
||||||
|
|
||||||
class PluginConfig(AppConfig):
|
|
||||||
"""
|
|
||||||
Subclass of Django's built-in AppConfig class, to be used for NetBox plugins.
|
|
||||||
"""
|
|
||||||
# Plugin metadata
|
|
||||||
author = ''
|
|
||||||
author_email = ''
|
|
||||||
description = ''
|
|
||||||
version = ''
|
|
||||||
|
|
||||||
# Root URL path under /plugins. If not set, the plugin's label will be used.
|
|
||||||
base_url = None
|
|
||||||
|
|
||||||
# Minimum/maximum compatible versions of NetBox
|
|
||||||
min_version = None
|
|
||||||
max_version = None
|
|
||||||
|
|
||||||
# Default configuration parameters
|
|
||||||
default_settings = {}
|
|
||||||
|
|
||||||
# Mandatory configuration parameters
|
|
||||||
required_settings = []
|
|
||||||
|
|
||||||
# Middleware classes provided by the plugin
|
|
||||||
middleware = []
|
|
||||||
|
|
||||||
# Django-rq queues dedicated to the plugin
|
|
||||||
queues = []
|
|
||||||
|
|
||||||
# Django apps to append to INSTALLED_APPS when plugin requires them.
|
|
||||||
django_apps = []
|
|
||||||
|
|
||||||
# Optional plugin resources
|
|
||||||
search_indexes = None
|
|
||||||
graphql_schema = None
|
|
||||||
menu = None
|
|
||||||
menu_items = None
|
|
||||||
template_extensions = None
|
|
||||||
user_preferences = None
|
|
||||||
|
|
||||||
def _load_resource(self, name):
|
|
||||||
# Import from the configured path, if defined.
|
|
||||||
if path := getattr(self, name, None):
|
|
||||||
return import_string(f"{self.__module__}.{path}")
|
|
||||||
|
|
||||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
|
||||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
|
||||||
default_module, resource_name = default_path.rsplit('.', 1)
|
|
||||||
try:
|
|
||||||
module = import_module(default_module)
|
|
||||||
return getattr(module, resource_name, None)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
plugin_name = self.name.rsplit('.', 1)[-1]
|
|
||||||
|
|
||||||
# Register search extensions (if defined)
|
|
||||||
search_indexes = self._load_resource('search_indexes') or []
|
|
||||||
for idx in search_indexes:
|
|
||||||
register_search(idx)
|
|
||||||
|
|
||||||
# Register template content (if defined)
|
|
||||||
if template_extensions := self._load_resource('template_extensions'):
|
|
||||||
register_template_extensions(template_extensions)
|
|
||||||
|
|
||||||
# Register navigation menu and/or menu items (if defined)
|
|
||||||
if menu := self._load_resource('menu'):
|
|
||||||
register_menu(menu)
|
|
||||||
if menu_items := self._load_resource('menu_items'):
|
|
||||||
register_menu_items(self.verbose_name, menu_items)
|
|
||||||
|
|
||||||
# Register GraphQL schema (if defined)
|
|
||||||
if graphql_schema := self._load_resource('graphql_schema'):
|
|
||||||
register_graphql_schema(graphql_schema)
|
|
||||||
|
|
||||||
# Register user preferences (if defined)
|
|
||||||
if user_preferences := self._load_resource('user_preferences'):
|
|
||||||
register_user_preferences(plugin_name, user_preferences)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def validate(cls, user_config, netbox_version):
|
|
||||||
|
|
||||||
# Enforce version constraints
|
|
||||||
current_version = version.parse(netbox_version)
|
|
||||||
if cls.min_version is not None:
|
|
||||||
min_version = version.parse(cls.min_version)
|
|
||||||
if current_version < min_version:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
|
|
||||||
)
|
|
||||||
if cls.max_version is not None:
|
|
||||||
max_version = version.parse(cls.max_version)
|
|
||||||
if current_version > max_version:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify required configuration settings
|
|
||||||
for setting in cls.required_settings:
|
|
||||||
if setting not in user_config:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of "
|
|
||||||
f"configuration.py."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply default configuration values
|
|
||||||
for setting, value in cls.default_settings.items():
|
|
||||||
if setting not in user_config:
|
|
||||||
user_config[setting] = value
|
|
||||||
|
@ -1,72 +1,7 @@
|
|||||||
from netbox.navigation import MenuGroup
|
import warnings
|
||||||
from utilities.choices import ButtonColorChoices
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
__all__ = (
|
from netbox.plugins.navigation import *
|
||||||
'PluginMenu',
|
|
||||||
'PluginMenuButton',
|
|
||||||
'PluginMenuItem',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenu:
|
# TODO: Remove in v4.0
|
||||||
icon_class = 'mdi mdi-puzzle'
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
|
|
||||||
def __init__(self, label, groups, icon_class=None):
|
|
||||||
self.label = label
|
|
||||||
self.groups = [
|
|
||||||
MenuGroup(label, items) for label, items in groups
|
|
||||||
]
|
|
||||||
if icon_class is not None:
|
|
||||||
self.icon_class = icon_class
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return slugify(self.label)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuItem:
|
|
||||||
"""
|
|
||||||
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
|
||||||
specifying additional link buttons that appear to the right of the item in the van menu.
|
|
||||||
|
|
||||||
Links are specified as Django reverse URL strings.
|
|
||||||
Buttons are each specified as a list of PluginMenuButton instances.
|
|
||||||
"""
|
|
||||||
permissions = []
|
|
||||||
buttons = []
|
|
||||||
|
|
||||||
def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None):
|
|
||||||
self.link = link
|
|
||||||
self.link_text = link_text
|
|
||||||
self.staff_only = staff_only
|
|
||||||
if permissions is not None:
|
|
||||||
if type(permissions) not in (list, tuple):
|
|
||||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
|
||||||
self.permissions = permissions
|
|
||||||
if buttons is not None:
|
|
||||||
if type(buttons) not in (list, tuple):
|
|
||||||
raise TypeError("Buttons must be passed as a tuple or list.")
|
|
||||||
self.buttons = buttons
|
|
||||||
|
|
||||||
|
|
||||||
class PluginMenuButton:
|
|
||||||
"""
|
|
||||||
This class represents a button within a PluginMenuItem. Note that button colors should come from
|
|
||||||
ButtonColorChoices.
|
|
||||||
"""
|
|
||||||
color = ButtonColorChoices.DEFAULT
|
|
||||||
permissions = []
|
|
||||||
|
|
||||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
|
||||||
self.link = link
|
|
||||||
self.title = title
|
|
||||||
self.icon_class = icon_class
|
|
||||||
if permissions is not None:
|
|
||||||
if type(permissions) not in (list, tuple):
|
|
||||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
|
||||||
self.permissions = permissions
|
|
||||||
if color is not None:
|
|
||||||
if color not in ButtonColorChoices.values():
|
|
||||||
raise ValueError("Button color must be a choice within ButtonColorChoices.")
|
|
||||||
self.color = color
|
|
||||||
|
@ -1,64 +1,7 @@
|
|||||||
import inspect
|
import warnings
|
||||||
|
|
||||||
from netbox.registry import registry
|
from netbox.plugins.registration import *
|
||||||
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
|
|
||||||
from .templates import PluginTemplateExtension
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'register_graphql_schema',
|
|
||||||
'register_menu',
|
|
||||||
'register_menu_items',
|
|
||||||
'register_template_extensions',
|
|
||||||
'register_user_preferences',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_template_extensions(class_list):
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
Register a list of PluginTemplateExtension classes
|
|
||||||
"""
|
|
||||||
# Validation
|
|
||||||
for template_extension in class_list:
|
|
||||||
if not inspect.isclass(template_extension):
|
|
||||||
raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
|
|
||||||
if not issubclass(template_extension, PluginTemplateExtension):
|
|
||||||
raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!")
|
|
||||||
if template_extension.model is None:
|
|
||||||
raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
|
|
||||||
|
|
||||||
registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
|
|
||||||
|
|
||||||
|
|
||||||
def register_menu(menu):
|
|
||||||
if not isinstance(menu, PluginMenu):
|
|
||||||
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
|
|
||||||
registry['plugins']['menus'].append(menu)
|
|
||||||
|
|
||||||
|
|
||||||
def register_menu_items(section_name, class_list):
|
|
||||||
"""
|
|
||||||
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)
|
|
||||||
"""
|
|
||||||
# Validation
|
|
||||||
for menu_link in class_list:
|
|
||||||
if not isinstance(menu_link, PluginMenuItem):
|
|
||||||
raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem")
|
|
||||||
for button in menu_link.buttons:
|
|
||||||
if not isinstance(button, PluginMenuButton):
|
|
||||||
raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton")
|
|
||||||
|
|
||||||
registry['plugins']['menu_items'][section_name] = class_list
|
|
||||||
|
|
||||||
|
|
||||||
def register_graphql_schema(graphql_schema):
|
|
||||||
"""
|
|
||||||
Register a GraphQL schema class for inclusion in NetBox's GraphQL API.
|
|
||||||
"""
|
|
||||||
registry['plugins']['graphql_schemas'].append(graphql_schema)
|
|
||||||
|
|
||||||
|
|
||||||
def register_user_preferences(plugin_name, preferences):
|
|
||||||
"""
|
|
||||||
Register a list of user preferences defined by a plugin.
|
|
||||||
"""
|
|
||||||
registry['plugins']['preferences'][plugin_name] = preferences
|
|
||||||
|
@ -1,73 +1,7 @@
|
|||||||
from django.template.loader import get_template
|
import warnings
|
||||||
|
|
||||||
__all__ = (
|
from netbox.plugins.templates import *
|
||||||
'PluginTemplateExtension',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginTemplateExtension:
|
# TODO: Remove in v4.0
|
||||||
"""
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
|
|
||||||
that are overridden by plugin authors to return template content.
|
|
||||||
|
|
||||||
The `model` attribute on the class defines the which model detail page this class renders content for. It
|
|
||||||
should be set as a string in the form '<app_label>.<model_name>'. render() provides the following context data:
|
|
||||||
|
|
||||||
* object - The object being viewed
|
|
||||||
* request - The current request
|
|
||||||
* settings - Global NetBox settings
|
|
||||||
* config - Plugin-specific configuration parameters
|
|
||||||
"""
|
|
||||||
model = None
|
|
||||||
|
|
||||||
def __init__(self, context):
|
|
||||||
self.context = context
|
|
||||||
|
|
||||||
def render(self, template_name, extra_context=None):
|
|
||||||
"""
|
|
||||||
Convenience method for rendering the specified Django template using the default context data. An additional
|
|
||||||
context dictionary may be passed as `extra_context`.
|
|
||||||
"""
|
|
||||||
if extra_context is None:
|
|
||||||
extra_context = {}
|
|
||||||
elif not isinstance(extra_context, dict):
|
|
||||||
raise TypeError("extra_context must be a dictionary")
|
|
||||||
|
|
||||||
return get_template(template_name).render({**self.context, **extra_context})
|
|
||||||
|
|
||||||
def left_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered on the left of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def right_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered on the right of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def full_width_page(self):
|
|
||||||
"""
|
|
||||||
Content that will be rendered within the full width of the detail page view. Content should be returned as an
|
|
||||||
HTML string. Note that content does not need to be marked as safe because this is automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def buttons(self):
|
|
||||||
"""
|
|
||||||
Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content
|
|
||||||
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
|
||||||
automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def list_buttons(self):
|
|
||||||
"""
|
|
||||||
Buttons that will be rendered and added to the existing list of buttons on the list view. Content
|
|
||||||
should be returned as an HTML string. Note that content does not need to be marked as safe because this is
|
|
||||||
automatically handled.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
@ -1,41 +1,7 @@
|
|||||||
from importlib import import_module
|
import warnings
|
||||||
|
|
||||||
from django.apps import apps
|
from netbox.plugins.urls import *
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls import include
|
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
|
||||||
from django.urls import path
|
|
||||||
from django.utils.module_loading import import_string, module_has_submodule
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
# Initialize URL base, API, and admin URL patterns for plugins
|
# TODO: Remove in v4.0
|
||||||
plugin_patterns = []
|
warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning)
|
||||||
plugin_api_patterns = [
|
|
||||||
path('', views.PluginsAPIRootView.as_view(), name='api-root'),
|
|
||||||
path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list')
|
|
||||||
]
|
|
||||||
plugin_admin_patterns = [
|
|
||||||
path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list')
|
|
||||||
]
|
|
||||||
|
|
||||||
# Register base/API URL patterns for each plugin
|
|
||||||
for plugin_path in settings.PLUGINS:
|
|
||||||
plugin = import_module(plugin_path)
|
|
||||||
plugin_name = plugin_path.split('.')[-1]
|
|
||||||
app = apps.get_app_config(plugin_name)
|
|
||||||
base_url = getattr(app, 'base_url') or app.label
|
|
||||||
|
|
||||||
# Check if the plugin specifies any base URLs
|
|
||||||
if module_has_submodule(plugin, 'urls'):
|
|
||||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
|
||||||
plugin_patterns.append(
|
|
||||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if the plugin specifies any API URLs
|
|
||||||
if module_has_submodule(plugin, 'api.urls'):
|
|
||||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
|
||||||
plugin_api_patterns.append(
|
|
||||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
|
||||||
)
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user