Merge branch 'feature' into list_all_objects_to_be_deleted

This commit is contained in:
Jeremy Stretch 2023-11-01 13:49:50 -04:00
commit 8a0f487393
252 changed files with 15900 additions and 1473 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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",

View File

@ -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",
]
}
```

View File

@ -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': {

View File

@ -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.

View File

@ -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.
![Adding the run action to a permission](../media/admin_ui_run_permission.png) ![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -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`.

View File

@ -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.
![Adding the run action to a permission](../media/admin_ui_run_permission.png) ![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@ -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 %}
``` ```

View File

@ -1,6 +1,6 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![NetBox](netbox_logo.svg "NetBox logo"){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.

View File

@ -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`.

View File

@ -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>

View File

@ -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.

View File

@ -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.

View 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

View File

@ -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`) |

View File

@ -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
--- ---

View File

@ -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'

View File

@ -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',
] ]

View File

@ -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(),

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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',
] ]

View 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),
),
]

View File

@ -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])

View File

@ -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')

View File

@ -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',
] ]

View File

@ -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'

View File

@ -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'),

View File

@ -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(

View File

@ -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,

View File

@ -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(

View File

@ -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,12 +60,13 @@ class DataSourceForm(NetBoxModelForm):
# Add backend-specific form fields # Add backend-specific form fields
self.backend_fields = [] self.backend_fields = []
for name, form_field in backend.parameters.items(): if backend:
field_name = f'backend_{name}' for name, form_field in backend.parameters.items():
self.backend_fields.append(field_name) field_name = f'backend_{name}'
self.fields[field_name] = copy.copy(form_field) self.backend_fields.append(field_name)
if self.instance and self.instance.parameters: self.fields[field_name] = copy.copy(form_field)
self.fields[field_name].initial = self.instance.parameters.get(name) if self.instance and self.instance.parameters:
self.fields[field_name].initial = self.instance.parameters.get(name)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -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)

View File

@ -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),
),
]

View 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),
),
]

View File

@ -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)"
}) })

View File

@ -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()

View 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

View File

@ -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')

View File

@ -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',

View File

@ -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/'
) )

View File

@ -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)

View File

@ -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/'
) )

View File

@ -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):

View File

@ -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',

View File

@ -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

View File

@ -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)'),

View File

@ -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()

View File

@ -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')

View File

@ -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}")

View File

@ -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
) )
) )

View File

@ -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(),

View File

@ -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={

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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),
),
]

View 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'),
),
]

View File

@ -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 this is the start of the path and no link exists, return None if len(path) == 1:
return None # If this is the start of the path and no link exists, return None
elif link is None: return 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:
is_complete = True # Check for non-symmetric path
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = True
elif len(remote_terminations) == 0:
is_complete = False
else:
# Unsupported topology, mark as split and exit
is_complete = False
is_split = True
break 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

View File

@ -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
) )
) )

View File

@ -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)
}) })

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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])
) )
}) })

View File

@ -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):
labels = [ """
f'Cable {cable}', Draw a single cable. Terminations and cable count are passed for determining position and padding
cable.get_status_display()
] :param cable: The cable to draw
if cable.type: :param terminations: List of terminations to build positioning data off of
labels.append(cable.get_type_display()) :param cable_count: Count of all cables on this layer for determining whether to collapse description into a
if cable.length and cable.length_unit: tooltip.
labels.append(f'{cable.length} {cable.get_length_unit_display()}') """
# If the cable count is higher than 2, collapse the description into a tooltip
if cable_count > 2:
# Use the cable __str__ function to denote the cable
labels = [f'{cable}']
# Include the label and the status description in the tooltip
description = [
f'Cable {cable}',
cable.get_status_display()
]
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
labels = [
f'Cable {cable}',
cable.get_status_display()
]
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
# If there is only one termination, center on that termination
# Otherwise average the center across the terminations
if len(terminations) == 1:
center = terminations[0].bottom_center[0]
else:
# Get a list of termination centers
termination_centers = [term.bottom_center[0] for term in terminations]
# Average the centers
center = sum(termination_centers) / len(termination_centers)
# Create the connector
connector = Connector( 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,34 +392,52 @@ 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)):
self.cursor += FANOUT_HEIGHT
fanin = True
if len(far_ends) > len(set(links)):
fanout = True
cursor = self.cursor
for link in links:
# Cable
if type(link) is Cable and not link_cables.get(link.pk):
# Reset cursor
self.cursor = cursor
# Generate a list of terminations connected to this cable
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
# Draw the cable
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
# Add cable to the list of cables
link_cables.update({link.pk: cable})
# Add cable to drawing
self.connectors.append(cable)
# Account for fan-ins height # Draw fan-ins
if len(near_ends) > 1: if len(near_ends) > 1 and fanin:
self.cursor += FANOUT_HEIGHT for term in terminations:
if term.object.cable == link:
self.draw_fanin(term, cable)
cable = self.draw_cable(link) # WirelessLink
self.connectors.append(cable) elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
# Draw fan-ins self.connectors.append(wirelesslink)
if len(near_ends) > 1:
for term in terminations:
self.draw_fanin(term, cable)
# WirelessLink
elif type(link) is WirelessLink:
wirelesslink = self.draw_wirelesslink(link)
self.connectors.append(wirelesslink)
# Far end termination(s) # Far end termination(s)
if len(far_ends) > 1: if len(far_ends) > 1:
self.cursor += FANOUT_HEIGHT if fanout:
terminations = self.draw_terminations(far_ends) self.cursor += FANOUT_HEIGHT
for term in terminations: terminations = self.draw_terminations(far_ends)
self.draw_fanout(term, cable) for term in terminations:
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
else:
self.draw_terminations(far_ends)
elif far_ends: elif far_ends:
self.draw_terminations(far_ends) self.draw_terminations(far_ends)
else: else:

View File

@ -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):

View File

@ -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',

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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):

View File

@ -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

View File

@ -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 {

View File

@ -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:

View File

@ -82,7 +82,10 @@ 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
] ]
return self.get_paginated_response(data) else:
data = []
return self.get_paginated_response(data)
# #

View File

@ -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')),
)

View File

@ -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,
) )

View File

@ -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 {

View File

@ -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>'),
} }

View File

@ -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)

View File

@ -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
help_text = self.fields[param.name].help_text # CUSTOM_VALIDATORS, which may reference Python objects.)
if help_text: try:
help_text += '<br />' # Line break json.dumps(value)
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
if is_static:
help_text += _(' (defined statically)')
elif value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
if type(value) in (tuple, list): if type(value) in (tuple, list):
value = ', '.join(value) self.fields[param.name].initial = ', '.join(value)
self.fields[param.name].initial = value else:
if is_static: 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].disabled = True
self.fields[param.name].help_text = _(
'This parameter has been defined statically and cannot be modified.'
)
continue
# Set the field's help text
help_text = self.fields[param.name].help_text
if help_text:
help_text += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&mdash;')
if value == param.default:
help_text += _(' (default)')
self.fields[param.name].help_text = help_text
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)

View File

@ -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}")

View 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')},
),
]

View File

@ -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}'}
) )

View File

@ -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:

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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