diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml
index 7cf1ea56c..a810b7a4c 100644
--- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml
@@ -15,7 +15,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v4.2.8
+ placeholder: v4.3.0
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml
index a39404a9d..5194e7bc6 100644
--- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml
@@ -27,7 +27,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
- placeholder: v4.2.8
+ placeholder: v4.3.0
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/lock-threads.yml b/.github/workflows/lock-threads.yml
index b77a84ad1..2148e563b 100644
--- a/.github/workflows/lock-threads.yml
+++ b/.github/workflows/lock-threads.yml
@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v5
+ - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
issue-inactive-days: 90
pr-inactive-days: 30
diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml
index 8a4f05456..3152232e2 100644
--- a/.github/workflows/update-translation-strings.yml
+++ b/.github/workflows/update-translation-strings.yml
@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
- uses: EndBug/add-and-commit@v9
+ uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with:
add: 'netbox/translations/'
default_author: github_actions
diff --git a/base_requirements.txt b/base_requirements.txt
index 9452cbd64..0c6e308e1 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -1,6 +1,6 @@
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
-Django<5.2
+Django==5.2.*
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -42,6 +42,10 @@ django-rich
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
django-rq
+# Provides a variety of storage backends
+# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst
+django-storages
+
# Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
django-tables2
@@ -78,6 +82,10 @@ gunicorn
# https://jinja.palletsprojects.com/changes/
Jinja2
+# JSON schema validation
+# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
+jsonschema
+
# Simple markup language for rendering HTML
# https://python-markdown.github.io/changelog/
Markdown
diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md
index 7cc4d3832..f702c3ffd 100644
--- a/docs/administration/replicating-netbox.md
+++ b/docs/administration/replicating-netbox.md
@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
!!! note
- These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
+ These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages).
### Archive the Media Directory
diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md
index 485e840e8..9e19622f9 100644
--- a/docs/configuration/plugins.md
+++ b/docs/configuration/plugins.md
@@ -33,3 +33,21 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
---
+## PLUGINS_CATALOG_CONFIG
+
+Default: Empty
+
+This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions.
+
+An example configuration is shown below:
+
+```python
+PLUGINS_CATALOG_CONFIG = {
+ 'hidden': [
+ 'plugin1',
+ ],
+ 'static': [
+ 'plugin2',
+ ],
+}
+```
diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md
index f7e5d71ce..4a18e8a6c 100644
--- a/docs/configuration/required-parameters.md
+++ b/docs/configuration/required-parameters.md
@@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*']
## DATABASE
-NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
+!!! warning "Legacy Configuration Parameter"
+ The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases.
+
+See the [`DATABASES`](#databases) configuration below for usage.
+
+---
+
+## DATABASES
+
+!!! info "This parameter was introduced in NetBox v4.3."
+
+NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
+
+```python
+DATABASES = {
+ 'default': {...},
+ 'external1': {...},
+ 'external2': {...},
+}
+```
+
+NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.)
+
+The following parameters must be defined for each database:
* `NAME` - Database name
* `USER` - PostgreSQL username
@@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 13 or later database service to store dat
Example:
```python
-DATABASE = {
- 'ENGINE': 'django.db.backends.postgresql',
- 'NAME': 'netbox', # Database name
- 'USER': 'netbox', # PostgreSQL username
- 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'NAME': 'netbox', # Database name
+ 'USER': 'netbox', # PostgreSQL username
+ 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age
+ }
}
```
@@ -53,7 +78,7 @@ DATABASE = {
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
!!! warning
- Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql.
+ The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used.
---
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
index 7f539bc96..771eba5c5 100644
--- a/docs/configuration/security.md
+++ b/docs/configuration/security.md
@@ -2,7 +2,10 @@
## ALLOW_TOKEN_RETRIEVAL
-Default: `True`
+Default: `False`
+
+!!! note
+ The default value of this parameter changed from true to false in NetBox v4.3.0.
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
@@ -186,6 +189,17 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
+## LOGIN_FORM_HIDDEN
+
+Default: False
+
+Option to hide the login form when only SSO authentication is in use.
+
+!!! warning
+ If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service.
+
+---
+
## LOGOUT_REDIRECT_URL
Default: `'home'`
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 1414cff85..fe01e40b1 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -12,6 +12,16 @@ BASE_PATH = 'netbox/'
---
+## DATABASE_ROUTERS
+
+!!! info "This parameter was introduced in NetBox v4.3."
+
+Default: `[]` (empty list)
+
+An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured.
+
+---
+
## DEFAULT_LANGUAGE
Default: `en-us` (US English)
@@ -75,6 +85,8 @@ HTTP_PROXIES = {
}
```
+If more flexibility is needed in determining which proxy to use for a given request, consider implementing one or more custom proxy routers via the [`PROXY_ROUTERS`](#proxy_routers) parameter.
+
---
## INTERNAL_IPS
@@ -160,6 +172,18 @@ The file path to the location where media files (such as image attachments) are
---
+## PROXY_ROUTERS
+
+!!! info "This parameter was introduced in NetBox v4.3."
+
+Default: `["utilities.proxy.DefaultProxyRouter"]`
+
+A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class.
+
+The `route()` method on each class must return a dictionary of candidate proxies arranged by protocol (e.g. `http` and/or `https`), or None if no viable proxy can be determined. The default class, `DefaultProxyRouter`, simply returns the content of [`HTTP_PROXIES`](#http_proxies).
+
+---
+
## REPORTS_ROOT
Default: `$INSTALL_ROOT/netbox/reports/`
@@ -184,23 +208,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
---
-## STORAGE_BACKEND
+## STORAGES
-Default: None (local storage)
+The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
-The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
+By default, the following configuration is used:
-The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
+```python
+STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
+ },
+ "scripts": {
+ "BACKEND": "extras.storage.ScriptFileSystemStorage",
+ },
+}
+```
----
+Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts.
-## STORAGE_CONFIG
+If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example:
-Default: Empty
+```python
+STORAGES = {
+ "scripts": {
+ "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
+ "OPTIONS": {
+ 'access_key': 'access key',
+ 'secret_key': 'secret key',
+ }
+ },
+}
+```
-A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
+The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html).
-If `STORAGE_BACKEND` is not defined, this setting will be ignored.
+!!! note
+ Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure.
---
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 7774ef35f..e7536a654 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files:
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
+**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage.
+
## Logging
The Script object provides a set of convenient functions for recording messages at different severity levels:
diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md
index 0bf020662..da0e49511 100644
--- a/docs/development/adding-models.md
+++ b/docs/development/adding-models.md
@@ -76,11 +76,13 @@ Create the following for each model:
## 13. GraphQL API components
-Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
+Create the following for each model:
-**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["policy"])` or similar.
+* GraphQL object type for the model in `graphql/types.py` (subclass the appropriate class from `netbox.graphql.types`)
+* Add a GraphQL filter for the model in `graphql/filters.py`
+* Extend the query class for the app in `graphql/schema.py` with the individual object and object list fields
-Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
+**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["foo"])` or similar.
## 14. Add tests
diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md
index 16d1c3451..d870a371d 100644
--- a/docs/development/extending-models.md
+++ b/docs/development/extending-models.md
@@ -6,7 +6,7 @@ Below is a list of tasks to consider when adding a new field to a core model.
Add the field to the model, taking care to address any of the following conditions.
-* When adding a GenericForeignKey field, also add an index under `Meta` for its two concrete fields. For example:
+* When adding a GenericForeignKey field, you may need add an index under `Meta` for its two concrete fields. (This is required only for non-unique GFK relationships, as the unique constraint introduces its own index.) For example:
```python
class Meta:
diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md
index 0b77bfd4d..129bf2d4b 100644
--- a/docs/development/getting-started.md
+++ b/docs/development/getting-started.md
@@ -115,7 +115,7 @@ You may also need to set up the yarn packages as shown in the [Web UI Developmen
Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters:
* `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes
-* `DATABASE`: PostgreSQL database connection parameters
+* `DATABASES`: PostgreSQL database connection parameters
* `REDIS`: Redis configuration (if different from the defaults)
* `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key)
* `DEBUG`: Set to `True`
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 8ba302909..0d74eea05 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -2,39 +2,17 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
-!!! warning "PostgreSQL 13 or later required"
- NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported.
+!!! warning "PostgreSQL 14 or later required"
+ NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
-=== "Ubuntu"
+```no-highlight
+sudo apt update
+sudo apt install -y postgresql
+```
- ```no-highlight
- sudo apt update
- sudo apt install -y postgresql
- ```
-
-=== "CentOS"
-
- ```no-highlight
- sudo yum install -y postgresql-server
- sudo postgresql-setup --initdb
- ```
-
- CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
-
- ```no-highlight
- host all all 127.0.0.1/32 md5
- host all all ::1/128 md5
- ```
-
- Once PostgreSQL has been installed, start the service and enable it to run at boot:
-
- ```no-highlight
- sudo systemctl enable --now postgresql
- ```
-
-Before continuing, verify that you have installed PostgreSQL 13 or later:
+Before continuing, verify that you have installed PostgreSQL 14 or later:
```no-highlight
psql -V
diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md
index 2756a1ab0..c29deb5c2 100644
--- a/docs/installation/2-redis.md
+++ b/docs/installation/2-redis.md
@@ -4,18 +4,9 @@
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
-=== "Ubuntu"
-
- ```no-highlight
- sudo apt install -y redis-server
- ```
-
-=== "CentOS"
-
- ```no-highlight
- sudo yum install -y redis
- sudo systemctl enable --now redis
- ```
+```no-highlight
+sudo apt install -y redis-server
+```
Before continuing, verify that your installed version of Redis is at least v4.0:
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 0a5f51702..67a19e2e3 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -9,17 +9,11 @@ Begin by installing all system packages required by NetBox and its dependencies.
!!! warning "Python 3.10 or later required"
NetBox supports Python 3.10, 3.11, and 3.12.
-=== "Ubuntu"
-
- ```no-highlight
- sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev
- ```
-
-=== "CentOS"
-
- ```no-highlight
- sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
- ```
+```no-highlight
+sudo apt install -y python3 python3-pip python3-venv python3-dev \
+build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev \
+libssl-dev zlib1g-dev
+```
Before continuing, check that your installed Python version is at least 3.10:
@@ -55,17 +49,9 @@ cd /opt/netbox/
If `git` is not already installed, install it:
-=== "Ubuntu"
-
- ```no-highlight
- sudo apt install -y git
- ```
-
-=== "CentOS"
-
- ```no-highlight
- sudo yum install -y git
- ```
+```no-highlight
+sudo apt install -y git
+```
Next, clone the git repository:
@@ -97,24 +83,12 @@ Using this installation method enables easy upgrades in the future by simply che
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
-=== "Ubuntu"
-
- ```
- sudo adduser --system --group netbox
- sudo chown --recursive netbox /opt/netbox/netbox/media/
- sudo chown --recursive netbox /opt/netbox/netbox/reports/
- sudo chown --recursive netbox /opt/netbox/netbox/scripts/
- ```
-
-=== "CentOS"
-
- ```
- sudo groupadd --system netbox
- sudo adduser --system -g netbox netbox
- sudo chown --recursive netbox /opt/netbox/netbox/media/
- sudo chown --recursive netbox /opt/netbox/netbox/reports/
- sudo chown --recursive netbox /opt/netbox/netbox/scripts/
- ```
+```
+sudo adduser --system --group netbox
+sudo chown --recursive netbox /opt/netbox/netbox/media/
+sudo chown --recursive netbox /opt/netbox/netbox/reports/
+sudo chown --recursive netbox /opt/netbox/netbox/scripts/
+```
## Configuration
@@ -128,7 +102,7 @@ sudo cp configuration_example.py configuration.py
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
-* `DATABASE`
+* `DATABASES` (or `DATABASE`)
* `REDIS`
* `SECRET_KEY`
@@ -146,18 +120,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins
ALLOWED_HOSTS = ['*']
```
-### DATABASE
+### DATABASES
-This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters.
+This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins.
+
+A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters.
```python
-DATABASE = {
- 'NAME': 'netbox', # Database name
- 'USER': 'netbox', # PostgreSQL username
- 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age (seconds)
+DATABASES = {
+ 'default': {
+ 'NAME': 'netbox', # Database name
+ 'USER': 'netbox', # PostgreSQL username
+ 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age (seconds)
+ }
}
```
@@ -207,7 +185,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
### Remote File Storage
-By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.
+By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`.
```no-highlight
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md
index 6ee1c9901..7de9f116d 100644
--- a/docs/installation/6-ldap.md
+++ b/docs/installation/6-ldap.md
@@ -6,18 +6,10 @@ This guide explains how to implement LDAP authentication using an external serve
### Install System Packages
-On Ubuntu:
-
```no-highlight
sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
```
-On CentOS:
-
-```no-highlight
-sudo yum install -y openldap-devel python3-devel
-```
-
### Install django-auth-ldap
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
diff --git a/docs/installation/index.md b/docs/installation/index.md
index 877b177eb..aefa39d17 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -1,9 +1,18 @@
# 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.
+- :material-clock-fast:{ .lg .middle } __Eager to Get Started?__
+
+ ---
+
+ Check out the [NetBox Cloud Free Plan](https://netboxlabs.com/free-netbox-cloud/)! Skip the installation process and grab your own NetBox Cloud instance, preconfigured and ready to go in minutes. Completely free!
+
+ [:octicons-arrow-right-24: Sign Up](https://signup.netboxlabs.com/)
+
+
+
+The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox:
@@ -19,7 +28,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
-| PostgreSQL | 13+ |
+| PostgreSQL | 14+ |
| Redis | 4.0+ |
Below is a simplified overview of the NetBox application stack for reference:
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index 42b8c0187..f9a7a3189 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -17,52 +17,50 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
NetBox requires the following dependencies:
-=== "Current Version"
+| Dependency | Supported Versions |
+|------------|--------------------|
+| Python | 3.10, 3.11, 3.12 |
+| PostgreSQL | 14+ |
+| Redis | 4.0+ |
- | Dependency | Supported Versions |
- |------------|--------------------|
- | Python | 3.10, 3.11, 3.12 |
- | PostgreSQL | 13+ |
- | Redis | 4.0+ |
-
-=== "All Versions"
-
- | NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
- |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
- | 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
- | 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
- | 4.0 | **3.10** | **3.12** | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
- | 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
- | 3.6 | 3.8 | **3.11** | **12** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
- | 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
- | 3.4 | 3.8 | 3.10 | **11** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
- | 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
- | 3.2 | **3.8** | **3.10** | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
- | 3.1 | 3.7 | 3.9 | **10** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
- | 3.0 | **3.7** | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
- | 2.11 | 3.6 | **3.9** | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
- | 2.10 | 3.6 | 3.8 | **9.6** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
- | 2.9 | 3.6 | 3.8 | 9.5 | **4.0** | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
- | 2.8 | **3.6** | **3.8** | **9.5** | **3.4** | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
- | 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
- | 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
- | 2.5 | **3.5** | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
- | 2.4 | **3.4** | **3.7** | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
- | 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
- | 2.2 | 2.7 | 3.6 | **9.4** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
- | 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
- | 2.0 | 2.7 | **3.6** | **9.3** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
- | 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
- | 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
- | 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
- | 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
- | 1.5 | 2.7 | 3.5 | **9.2** | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
- | 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
- | 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
- | 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
- | 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
- | 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
+### Version History
+| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
+|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
+| 4.3 | 3.10 | 3.12 | 14 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
+| 4.2 | 3.10 | 3.12 | 13 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
+| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
+| 4.0 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
+| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
+| 3.6 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
+| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
+| 3.4 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
+| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
+| 3.2 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
+| 3.1 | 3.7 | 3.9 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
+| 3.0 | 3.7 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
+| 2.11 | 3.6 | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
+| 2.10 | 3.6 | 3.8 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
+| 2.9 | 3.6 | 3.8 | 9.5 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
+| 2.8 | 3.6 | 3.8 | 9.5 | 3.4 | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
+| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
+| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
+| 2.5 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
+| 2.4 | 3.4 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
+| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
+| 2.2 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
+| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
+| 2.0 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
+| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
+| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
+| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
+| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
+| 1.5 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
+| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
+| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
+| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
+| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
+| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
## 3. Install the Latest Release
diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md
index 87d9d8c49..39309671c 100644
--- a/docs/integrations/graphql-api.md
+++ b/docs/integrations/graphql-api.md
@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
---data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
+--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}'
```
The response will include the requested data formatted as JSON:
@@ -51,20 +51,48 @@ For more detail on constructing GraphQL queries, see the [GraphQL queries docume
## Filtering
-The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
+!!! note "Changed in NetBox v4.3"
+ The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3.
+
+Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites:
```
query {
- site_list(filters: {region: "us-nc", status: "active"}) {
+ site_list(
+ filters: {
+ status: STATUS_ACTIVE
+ }
+ ) {
name
}
}
```
-In addition, filtering can be done on list of related objects as shown in the following query:
+Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo:
```
-{
+query {
+ site_list(
+ filters: {
+ status: STATUS_PLANNED,
+ OR: {
+ tenant: {
+ name: {
+ exact: "Foo"
+ }
+ }
+ }
+ }
+ ) {
+ name
+ }
+}
+```
+
+Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device:
+
+```
+query {
device_list {
id
name
@@ -103,6 +131,18 @@ Certain queries can return multiple types of objects, for example cable terminat
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
+## Pagination
+
+Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below:
+
+```
+query {
+ device_list(pagination: { offset: 0, limit: 20 }) {
+ id
+ }
+}
+```
+
## Authentication
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
diff --git a/docs/introduction.md b/docs/introduction.md
index 75701c119..c8e5ee8ac 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
-| Database | PostgreSQL 13+ |
+| Database | PostgreSQL 14+ |
| Task queuing | Redis/django-rq |
diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md
index 0e18a2aae..527d93939 100644
--- a/docs/models/core/datasource.md
+++ b/docs/models/core/datasource.md
@@ -44,6 +44,12 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
+### Sync Interval
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually.
+
### Last Synced
The date and time at which the source was most recently synchronized successfully.
diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md
index 786170f2b..abff149d6 100644
--- a/docs/models/dcim/devicerole.md
+++ b/docs/models/dcim/devicerole.md
@@ -4,6 +4,12 @@ Devices can be organized by functional roles, which are fully customizable by th
## Fields
+### Parent
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The parent role of which this role is a child (optional).
+
### Name
A unique human-friendly name.
diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md
index 2d648341b..6aed0fc86 100644
--- a/docs/models/dcim/inventoryitem.md
+++ b/docs/models/dcim/inventoryitem.md
@@ -1,5 +1,8 @@
# Inventory Items
+!!! warning "Deprecation Warning"
+ Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes.
+
Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes.
Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface.
diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md
index 50eb61abd..b77637604 100644
--- a/docs/models/dcim/inventoryitemrole.md
+++ b/docs/models/dcim/inventoryitemrole.md
@@ -1,5 +1,8 @@
# Inventory Item Roles
+!!! warning "Deprecation Warning"
+ Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes.
+
Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc.
## Fields
diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md
index 02fde5995..7d8ff504d 100644
--- a/docs/models/dcim/inventoryitemtemplate.md
+++ b/docs/models/dcim/inventoryitemtemplate.md
@@ -1,3 +1,6 @@
# Inventory Item Templates
+!!! warning "Deprecation Warning"
+ Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes.
+
A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. See the [inventory item](./inventoryitem.md) documentation for more detail.
diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md
index 7077e16c2..88f04466a 100644
--- a/docs/models/dcim/moduletype.md
+++ b/docs/models/dcim/moduletype.md
@@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
### Airflow
The direction in which air circulates through the device chassis for cooling.
+
+### Profile
+
+The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
+
+### Attributes
+
+Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.
diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md
new file mode 100644
index 000000000..80345c82b
--- /dev/null
+++ b/docs/models/dcim/moduletypeprofile.md
@@ -0,0 +1,40 @@
+# Module Type Profiles
+
+!!! info "This model was introduced in NetBox v4.3."
+
+Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
+
+Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.
+
+```json
+{
+ "properties": {
+ "type": {
+ "type": "string",
+ "title": "Disk type",
+ "enum": ["HD", "SSD", "NVME"],
+ "default": "HD"
+ },
+ "capacity": {
+ "type": "integer",
+ "title": "Capacity (GB)",
+ "description": "Gross disk size"
+ },
+ "speed": {
+ "type": "integer",
+ "title": "Speed (RPM)"
+ }
+ },
+ "required": [
+ "type", "capacity"
+ ]
+}
+```
+
+The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed.
+
+## Fields
+
+### Schema
+
+This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null).
diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md
index a99f60b23..22a7ec63e 100644
--- a/docs/models/dcim/poweroutlet.md
+++ b/docs/models/dcim/poweroutlet.md
@@ -29,6 +29,19 @@ An alternative physical label identifying the power outlet.
The type of power outlet.
+### Status
+
+The operational status of the power outlet. By default, the following statuses are available:
+
+* Enabled
+* Disabled
+* Faulty
+
+!!! tip "Custom power outlet statuses"
+ Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+!!! info "This field was introduced in NetBox v4.3."
+
### Color
!!! info "This field was introduced in NetBox v4.2."
diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md
index b5f2d99e7..5298e8b26 100644
--- a/docs/models/dcim/racktype.md
+++ b/docs/models/dcim/racktype.md
@@ -40,7 +40,9 @@ The number of the numerically lowest unit in the rack. This value defaults to on
### Outer Dimensions
-The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
+The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
+
+!!! info "The `outer_height` field was introduced in NetBox v4.3."
### Mounting Depth
diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md
deleted file mode 100644
index 4599fed85..000000000
--- a/docs/models/extras/branch.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Branches
-
-!!! danger "Deprecated Feature"
- This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality.
-
-A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
-
-## Fields
-
-### Name
-
-The branch's name.
-
-### User
-
-The user to which the branch belongs (optional).
diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md
index b580d6885..6b245e5e9 100644
--- a/docs/models/extras/configtemplate.md
+++ b/docs/models/extras/configtemplate.md
@@ -12,10 +12,6 @@ See the [configuration rendering documentation](../../features/configuration-ren
A unique human-friendly name.
-### Weight
-
-A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
-
### Data File
Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file.
@@ -27,3 +23,27 @@ Jinja2 template code, if being defined locally rather than replicated from a dat
### Environment Parameters
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
+
+### MIME Type
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`.
+
+### File Name
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The file name to give to the rendered export file (optional).
+
+### File Extension
+
+!!! info "This field was introduced in NetBox v4.3."
+
+The file extension to append to the file name in the response (optional).
+
+### As Attachment
+
+!!! info "This field was introduced in NetBox v4.3."
+
+If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported).
\ No newline at end of file
diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md
index d2f9292c6..86e1ae04a 100644
--- a/docs/models/extras/exporttemplate.md
+++ b/docs/models/extras/exporttemplate.md
@@ -20,10 +20,20 @@ Template code may optionally be sourced from a remote [data file](../core/datafi
Jinja2 template code for rendering the exported data.
+### Environment Parameters
+
+!!! info "This field was introduced in NetBox v4.3."
+
+A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
+
### MIME Type
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
+### File Name
+
+The file name to give to the rendered export file (optional).
+
### File Extension
The file extension to append to the file name in the response (optional).
diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md
deleted file mode 100644
index 0693a32d3..000000000
--- a/docs/models/extras/stagedchange.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Staged Changes
-
-!!! danger "Deprecated Feature"
- This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality.
-
-A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md).
-
-Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method.
-
-## Fields
-
-!!! warning
- Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager.
-
-### Branch
-
-The [branch](./branch.md) to which this change belongs.
-
-### Action
-
-The type of action this change represents: `create`, `update`, or `delete`.
-
-### Object
-
-A generic foreign key referencing the existing object to which this change applies.
-
-### Data
-
-JSON representation of the changes being made to the object (not applicable for deletions).
diff --git a/docs/models/extras/tableconfig.md b/docs/models/extras/tableconfig.md
new file mode 100644
index 000000000..e5484ec64
--- /dev/null
+++ b/docs/models/extras/tableconfig.md
@@ -0,0 +1,43 @@
+# Table Configs
+
+This object represents the saved configuration of an object table in NetBox. Table configs can be crafted, saved, and shared among users to apply specific views within object lists. Each table config can specify which table columns to display, the order in which to display them, and which columns are used for sorting.
+
+For example, you might wish to create a table config for the devices list to assist in inventory tasks. This view might show the device name, location, serial number, and asset tag, but omit operational details like IP addresses. Once applied, this table config can be saved for reuse in future audits.
+
+## Fields
+
+### Name
+
+A human-friendly name for the table config.
+
+### User
+
+The user to which this filter belongs. The current user will be assigned automatically when saving a table config via the UI, and cannot be changed.
+
+### Object Type
+
+The type of NetBox object to which the table config pertains.
+
+### Table
+
+The name of the specific table to which the table config pertains. (Some NetBox object use multiple tables.)
+
+### Weight
+
+A numeric weight used to influence the order in which table configs are listed. Table configs with a lower weight will be listed before those with a higher weight. Table configs having the same weight will be ordered alphabetically.
+
+### Enabled
+
+Determines whether this table config can be used. Disabled table configs will not appear as options in the UI, however they will be included in API results.
+
+### Shared
+
+Determines whether this table config is intended for use by all users or only its owner. Note that deselecting this option does **not** hide the table config from other users; it is merely excluded from the list of available table configs in UI object list views.
+
+### Ordering
+
+A list of column names by which the table is to be ordered. If left blank, the table's default ordering will be used.
+
+### Columns
+
+A list of columns to be displayed in the table. The table will render these columns in the order they appear in the list. At least one column must be selected.
diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md
index 39de48261..c4bc91b5a 100644
--- a/docs/models/extras/tag.md
+++ b/docs/models/extras/tag.md
@@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
The color to use when displaying the tag in the NetBox UI.
+### Weight
+
+A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
+
+!!! info "This field was introduced in NetBox v4.3."
+
### Object Types
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md
index 71f0884d9..fd439998a 100644
--- a/docs/models/ipam/iprange.md
+++ b/docs/models/ipam/iprange.md
@@ -2,6 +2,12 @@
This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md).
+Each IP range can be marked as populated, which instructs NetBox to treat the range as though every IP address within it has been created (even though these individual IP addresses don't actually exist in the database). This can be helpful in scenarios where the management of a subset of IP addresses has been deferred to an external system of record, such as a DHCP server. NetBox will prohibit the creation of individual IP addresses within a range that has been marked as populated.
+
+An IP range can also be marked as utilized. This will cause its utilization to always be reported as 100% when viewing the range or when calculating the utilization of a parent prefix. (If not enabled, a range's utilization is calculated based on the number of IP addresses which have been created within it.)
+
+Typically, IP ranges marked as populated should also be marked as utilized, although there may be scenarios where this is undesirable (e.g. when reclaiming old IP space). An IP range which has been marked as populated but _not_ marked as utilized will always report a utilization of 0%, as it cannot contain child IP addresses.
+
## Fields
### VRF
@@ -29,6 +35,12 @@ The IP range's operational status. Note that the status of a range does _not_ ha
!!! tip
Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+### Mark Populated
+
+!!! note "This field was added in NetBox v4.3."
+
+If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any).
+
### Mark Utilized
If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example.
diff --git a/docs/models/ipam/service.md b/docs/models/ipam/service.md
index 316828b61..0d5f12a17 100644
--- a/docs/models/ipam/service.md
+++ b/docs/models/ipam/service.md
@@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s
## Fields
+### Parent
+
+The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
+[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
+
+!!! note "Changed in NetBox v4.3"
+
+ Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field.
+
### Name
A service or protocol name.
diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md
index eac630180..f277ab499 100644
--- a/docs/models/tenancy/contact.md
+++ b/docs/models/tenancy/contact.md
@@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj
## Fields
-### Group
+### Groups
-The [contact group](./contactgroup.md) to which this contact is assigned (if any).
+The [contact groups](./contactgroup.md) to which this contact is assigned (if any).
+
+!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group."
### Name
diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md
index 1167c1c17..983095ef8 100644
--- a/docs/models/vpn/l2vpn.md
+++ b/docs/models/vpn/l2vpn.md
@@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include:
!!! note
Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations.
+### Status
+
+The operational status of the L2VPN. By default, the following statuses are available:
+
+* Active (default)
+* Planned
+* Faulty
+
+!!! tip "Custom L2VPN statuses"
+ Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
+!!! info "This field was introduced in NetBox v4.3."
+
### Identifier
An optional numeric identifier. This can be used to track a pseudowire ID, for example.
diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md
index d803ce2f4..e19b3a733 100644
--- a/docs/plugins/development/filtersets.md
+++ b/docs/plugins/development/filtersets.md
@@ -1,6 +1,6 @@
# Filters & Filter Sets
-Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
+Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
## FilterSet Classes
@@ -61,6 +61,11 @@ class MyModelViewSet(...):
The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
+This class filters `tags` using the `slug` field. For example:
+
+`GET /api/dcim/sites/?tag=alpha&tag=bravo`
+
+
```python
from django_filters import FilterSet
from extras.filters import TagFilter
@@ -68,3 +73,19 @@ from extras.filters import TagFilter
class MyModelFilterSet(FilterSet):
tag = TagFilter()
```
+
+### TagIDFilter
+
+The `TagIDFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
+
+This class filters `tags` using the `id` field. For example:
+
+`GET /api/dcim/sites/?tag_id=100&tag_id=200`
+
+```python
+from django_filters import FilterSet
+from extras.filters import TagIDFilter
+
+class MyModelFilterSet(FilterSet):
+ tag_id = TagIDFilter()
+```
diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md
index 9dbf23e84..56bde5e41 100644
--- a/docs/plugins/development/index.md
+++ b/docs/plugins/development/index.md
@@ -103,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
+| `release_track` | An alternate release track (e.g. `dev` or `beta`) to which a release belongs |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md
index 03cedda16..508c4ce89 100644
--- a/docs/plugins/development/models.md
+++ b/docs/plugins/development/models.md
@@ -117,6 +117,10 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.CloningMixin
+::: netbox.models.features.ContactsMixin
+
+!!! info "Plugin support for ContactsMixin was introduced in NetBox v4.3."
+
::: netbox.models.features.CustomLinksMixin
::: netbox.models.features.CustomFieldsMixin
@@ -125,9 +129,6 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.EventRulesMixin
-!!! note
- `EventRulesMixin` was renamed from `WebhooksMixin` in NetBox v3.7.
-
::: netbox.models.features.ExportTemplatesMixin
::: netbox.models.features.JobsMixin
diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md
index 90b523473..b5e2694b4 100644
--- a/docs/plugins/development/navigation.md
+++ b/docs/plugins/development/navigation.md
@@ -64,13 +64,14 @@ item1 = PluginMenuItem(
A `PluginMenuItem` has the following attributes:
-| Attribute | Required | Description |
-|---------------|----------|----------------------------------------------------------------------------------------------------------|
-| `link` | Yes | Name of the URL path to which this menu item links |
-| `link_text` | Yes | The text presented to the user |
-| `permissions` | - | A list of permissions required to display this link |
-| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
-| `buttons` | - | An iterable of PluginMenuButton instances to include |
+| Attribute | Required | Description |
+|-----------------|----------|----------------------------------------------------------------------------------------------------------|
+| `link` | Yes | Name of the URL path to which this menu item links |
+| `link_text` | Yes | The text presented to the user |
+| `permissions` | - | A list of permissions required to display this link |
+| `auth_required` | - | Display only for authenticated users |
+| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
+| `buttons` | - | An iterable of PluginMenuButton instances to include |
## Menu Buttons
diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md
deleted file mode 100644
index a8fd1d232..000000000
--- a/docs/plugins/development/staged-changes.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Staged Changes
-
-!!! danger "Deprecated Feature"
- This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality.
-
-NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example.
-
-To begin staging changes, first create a [branch](../../models/extras/branch.md):
-
-```python
-from extras.models import Branch
-
-branch1 = Branch.objects.create(name='branch1')
-```
-
-Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction.
-
-```python
-from extras.models import Branch
-from netbox.staging import checkout
-
-branch1 = Branch.objects.get(name='branch1')
-with checkout(branch1):
- Site.objects.create(name='New Site', slug='new-site')
- # ...
-```
-
-Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch.
-
-To apply the changes within a branch, call the branch's `commit()` method:
-
-```python
-from extras.models import Branch
-
-branch1 = Branch.objects.get(name='branch1')
-branch1.commit()
-```
-
-Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused).
diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md
index e3740de59..43cc0ce82 100644
--- a/docs/plugins/development/views.md
+++ b/docs/plugins/development/views.md
@@ -198,6 +198,7 @@ Plugins can inject custom content into certain areas of core NetBox views. This
| Method | View | Description |
|---------------------|-------------|-----------------------------------------------------|
+| `head()` | All | Custom HTML `` block includes |
| `navbar()` | All | Inject content inside the top navigation bar |
| `list_buttons()` | List view | Add buttons to the top of the page |
| `buttons()` | Object view | Add buttons to the top of the page |
diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md
index d996224c1..0d0b10092 100644
--- a/docs/release-notes/index.md
+++ b/docs/release-notes/index.md
@@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
+#### [Version 4.3](./version-4.3.md) (May 2025)
+
+* Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002))
+* Reusable Table Configurations ([#14591](https://github.com/netbox-community/netbox/issues/14591))
+* Option to Treat IP Ranges as Fully Populated ([#9763](https://github.com/netbox-community/netbox/issues/9763))
+* Hierarchical Device Roles ([#18245](https://github.com/netbox-community/netbox/issues/18245))
+* Periodic Synchronization of Data Sources ([#18287](https://github.com/netbox-community/netbox/issues/18287))
+* Proxy Routing ([#18627](https://github.com/netbox-community/netbox/issues/18627))
+
#### [Version 4.2](./version-4.2.md) (January 2025)
* Assign Multiple MAC Addresses per Interface ([#4867](https://github.com/netbox-community/netbox/issues/4867))
diff --git a/docs/release-notes/version-4.2.md b/docs/release-notes/version-4.2.md
index 612fb9b02..45484f406 100644
--- a/docs/release-notes/version-4.2.md
+++ b/docs/release-notes/version-4.2.md
@@ -1,5 +1,42 @@
# NetBox v4.2
+## v4.2.9 (2025-04-30)
+
+### Enhancements
+
+* [#17151](https://github.com/netbox-community/netbox/issues/17151) - Display circuit type with background color in circuits list
+* [#17319](https://github.com/netbox-community/netbox/issues/17319) - Improve layout of component template edit forms
+* [#17405](https://github.com/netbox-community/netbox/issues/17405) - Display plugin icons in plugins list
+* [#18215](https://github.com/netbox-community/netbox/issues/18215) - Link to script results list from script history
+* [#18334](https://github.com/netbox-community/netbox/issues/18334) - Add region, site group, site, location, and rack filters for modules
+* [#18982](https://github.com/netbox-community/netbox/issues/18982) - Reference rack as related object in changelog records for rack reservations
+* [#18989](https://github.com/netbox-community/netbox/issues/18989) - List virtual circuits under provider view
+* [#19110](https://github.com/netbox-community/netbox/issues/19110) - Enable filtering devices and virtual machines by primary IP address
+* [#19358](https://github.com/netbox-community/netbox/issues/19358) - Move release info from footer to the navigation menu
+
+### Bug Fixes
+
+* [#15739](https://github.com/netbox-community/netbox/issues/15739) - Account for parallel cables when calculating total path length
+* [#15971](https://github.com/netbox-community/netbox/issues/15971) - Preserve "none" selection in filter form fields
+* [#16238](https://github.com/netbox-community/netbox/issues/16238) - Fix styling for white, gray, and black custom link buttons
+* [#17613](https://github.com/netbox-community/netbox/issues/17613) - Fix layout of object view content on mobile
+* [#17676](https://github.com/netbox-community/netbox/issues/17676) - Fix support for module bay creation when bulk importing module types
+* [#18706](https://github.com/netbox-community/netbox/issues/18706) - Fix validation for VLANs assigned to both a group and a site
+* [#18717](https://github.com/netbox-community/netbox/issues/18717) - Ensure change logs populated for many-to-one changes
+* [#19117](https://github.com/netbox-community/netbox/issues/19117) - Avoid `AttributeError` exception when bulk import objects which have a multi-object custom field with a default value
+* [#19204](https://github.com/netbox-community/netbox/issues/19204) - Improve JSON serialization support for data returned by a custom script
+* [#19217](https://github.com/netbox-community/netbox/issues/19217) - Ensure static assets for the debug toolbar are installed even if `DEBUG` is false
+* [#19228](https://github.com/netbox-community/netbox/issues/19228) - Fix ordering of custom scripts to avoid `NoReverseMatch` exception
+* [#19229](https://github.com/netbox-community/netbox/issues/19229) - Fix `ValueError` exception when attempting to nullify interface mode when a VLAN is assigned
+* [#19275](https://github.com/netbox-community/netbox/issues/19275) - `type` field should not be required when bulk editing interfaces
+* [#19279](https://github.com/netbox-community/netbox/issues/19279) - `status` field should not be required when bulk editing inventory items
+* [#19281](https://github.com/netbox-community/netbox/issues/19281) - Fix form validation failure when attempting to create a service from a service template
+* [#19320](https://github.com/netbox-community/netbox/issues/19320) - Include Q-in-Q VLAN (if any) in VM interface details
+* [#19322](https://github.com/netbox-community/netbox/issues/19322) - Correct URL paths for bulk import views
+* [#19346](https://github.com/netbox-community/netbox/issues/19346) - Ensure all redirect URLs are validated before use
+
+---
+
## v4.2.8 (2025-04-22)
### Enhancements
diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md
new file mode 100644
index 000000000..07d6b66f1
--- /dev/null
+++ b/docs/release-notes/version-4.3.md
@@ -0,0 +1,134 @@
+## v4.3.0 (2025-05-01)
+
+### Breaking Changes
+
+* The GraphQL API Now uses an advanced syntax for filtering, to enable e.g. logical AND/OR filtering and custom field lookups.
+* PostgreSQL 13 is no longer supported. NetBox v4.3 requires PostgreSQL 14.0 or later.
+* The `ALLOW_TOKEN_RETRIEVAL` configuration parameter now defaults to False.
+* The `device` and `virtual_machine` foreign keys on the Service model have been replaced with a generic `parent` relationship to support the assignment of services to FHRP groups as well.
+* The `group` foreign key on the Contact model has been replaced with a many-to-many `groups` field.
+* `django-storages` is now a required dependency. (It will be installed automatically on upgrade.)
+* PluginTemplateExtension no longer supports registration via the singular `model` attribute (use `models` instead).
+* The legacy staged changes functionality has been removed.
+
+### New Features
+
+#### Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002))
+
+The new [module type profile](../models/dcim/moduletypeprofile.md) model enables users to declare custom profiles for module types, with the ability to define custom attributes for each profile according to its functional role. For example, a CPU module type might declare architecture and clock speed attributes; a hard disk profile might declare attributes for type and speed.
+
+Attributes can be declared on each profile using [JSON schema](https://json-schema.org/), which allows for attributes to be declared as strings (text), integers, decimals, booleans, or choice fields. Profile attributes render as individual form fields when modifying a module type. Several profiles have been included by default to serve as examples, however these may be modified or removed.
+
+#### Reusable Table Configurations ([#14591](https://github.com/netbox-community/netbox/issues/14591))
+
+After modifying the displayed columns and/or ordering for a specific object table in the user interface, users now have the option to save that configuration so that it can be reused in the future. Similar to saved filters, table configs can be shared with other users to easily replicate table layouts crafted to serve specific use cases.
+
+#### Option to Treat IP Ranges as Fully Populated ([#9763](https://github.com/netbox-community/netbox/issues/9763))
+
+A new `mark_populated` boolean field has been added to the IPRange model. If set to true, NetBox will consider the IP range to be fully populated, and will not permit the creation of individual IP addresses within the range. For example, you might defer the management of an IP range to an external DHCP server, and wish for NetBox to treat the range as a opaque monolithic block for planning and allocation purposes.
+
+#### Hierarchical Device Roles ([#18245](https://github.com/netbox-community/netbox/issues/18245))
+
+Device roles can now be arranged hierarchically, with one role optionally serving as a parent to one or more child roles. For example, you might wish to create a generic "Server" role for devices with "Application Server" and "Database Server" roles beneath it. A device could then be assigned to any of these three roles.
+
+#### Periodic Synchronization of Data Sources ([#18287](https://github.com/netbox-community/netbox/issues/18287))
+
+Data sources can now be configured to synchronize automatically at a specified interval, as indicated by the new `sync_interval` field. No additional system configuration is necessary to support this functionality; background jobs will be scheduled automatically by the RQ worker process.
+
+#### Proxy Routing ([#18627](https://github.com/netbox-community/netbox/issues/18627))
+
+User can now declare one or more proxy routers via the `PROXY_ROUTERS` configuration parameter to control the use of specific proxy servers for various outbound connections. For example, it is now possible to configure NetBox to use different proxies based on the type of outbound traffic or its destination.
+
+### Enhancements
+
+* [#7598](https://github.com/netbox-community/netbox/issues/7598) - Adopt advanced query filtering in GraphQL API to support filtering by custom fields
+* [#8423](https://github.com/netbox-community/netbox/issues/8423) - Enable assigning services to FHRP groups
+* [#15842](https://github.com/netbox-community/netbox/issues/15842) - Introduce the `LOGIN_FORM_HIDDEN` configuration parameter
+* [#16224](https://github.com/netbox-community/netbox/issues/16224) - Implement pagination support for the GraphQL API
+* [#17170](https://github.com/netbox-community/netbox/issues/17170) - Enable the assignment of a contact to multiple contact groups
+* [#17443](https://github.com/netbox-community/netbox/issues/17443) - Add a `file_name` field to the export template model
+* [#17602](https://github.com/netbox-community/netbox/issues/17602) - Add a `comments` field to all nested group models (Region, SiteGroup, Location, ContactGroup, TenantGroup, and WirelessLANGroup)
+* [#17608](https://github.com/netbox-community/netbox/issues/17608) - Add a `status` field to the L2VPN model
+* [#17653](https://github.com/netbox-community/netbox/issues/17653) - Enable declaring Jinja environment parameters on export templates (similar to config templates)
+* [#17793](https://github.com/netbox-community/netbox/issues/17793) - Introduce a REST API endpoint for tagged objects (`/api/extras/tagged-objects/`)
+* [#17841](https://github.com/netbox-community/netbox/issues/17841) - Add a `weight` field to the Tag model to influence ordering
+* [#18296](https://github.com/netbox-community/netbox/issues/18296) - Add a `tenant` field to the VLAN group model
+* [#18352](https://github.com/netbox-community/netbox/issues/18352) - Add a `status` field to the power outlet model
+* [#18417](https://github.com/netbox-community/netbox/issues/18417) - Add an `outer_height` field to the rack & rack type models
+* [#18535](https://github.com/netbox-community/netbox/issues/18535) - The presence of incompatible plugins will no longer prevent NetBox from starting
+* [#18780](https://github.com/netbox-community/netbox/issues/18780) - Introduce `DATABASES` and `DATABASE_ROUTERS` configuration parameters to enable defining connections to external databases (e.g. for plugins)
+* [#18783](https://github.com/netbox-community/netbox/issues/18783) - Enable filtering all applicable models by tag ID
+* [#18785](https://github.com/netbox-community/netbox/issues/18785) - Enable custom choices for rack, device, and module airflow
+* [#18896](https://github.com/netbox-community/netbox/issues/18896) - Enable the use of remote storage for custom scripts
+
+### Plugins
+
+* [#16630](https://github.com/netbox-community/netbox/issues/16630) - Plugins can now inject content within the HTML `` block via the new `plugin_head()` method on PluginTemplateExtension
+* [#17424](https://github.com/netbox-community/netbox/issues/17424) - Extend ViewTab with a `visible` argument to control tab rendering
+* [#17857](https://github.com/netbox-community/netbox/issues/17857) - Added a `release_track` attribute to PluginConfig
+* [#18305](https://github.com/netbox-community/netbox/issues/18305) - Introduce plugin support for ContactsMixin
+* [#19073](https://github.com/netbox-community/netbox/issues/19073) - Allow installed plugins to be omitted from the plugins list
+
+### Other Changes
+
+* [#18071](https://github.com/netbox-community/netbox/issues/18071) - Removed legacy staged changed functionality in favor of the [netbox-branching](https://github.com/netboxlabs/netbox-branching) plugin
+* [#18072](https://github.com/netbox-community/netbox/issues/18072) - Drop support for the singular `model` attribute on PluginTemplateExtension (use `models` instead)
+* [#18191](https://github.com/netbox-community/netbox/issues/18191) - Remove redundant PostgreSQL indexes
+* [#18236](https://github.com/netbox-community/netbox/issues/18236) - Upgrade the HTMX library to v2.0
+* [#18540](https://github.com/netbox-community/netbox/issues/18540) - Operational plugins are now recorded in the application registry
+* [#18623](https://github.com/netbox-community/netbox/issues/18623) - Upgrade the Tabler CSS theme to v1.2
+* [#18743](https://github.com/netbox-community/netbox/issues/18743) - Upgrade Django to v5.2
+* [#18751](https://github.com/netbox-community/netbox/issues/18751) - Change the default value for `ALLOW_TOKEN_RETRIEVAL` to False
+* [#18808](https://github.com/netbox-community/netbox/issues/18808) - Squashed migration dependencies have been altered to rectify an issue with Django's `sqlmigrate` management command
+* [#18820](https://github.com/netbox-community/netbox/issues/18820) - PostgreSQL 13 is no longer supported
+* [#19004](https://github.com/netbox-community/netbox/issues/19004) - The use of inventory items has been deprecated in favor of modules. Inventory items and roles may be removed in a future NetBox release.
+
+### REST API Changes
+
+* Added the following endpoints:
+ * `/api/extras/table-configs/`
+ * `/api/extras/tagged-objects/`
+ * `/api/dcim/module-type-profiles/`
+* core.DataSource
+ * Added the optional `sync_interval` field
+* dcim.DeviceRole
+ * Added the optional `parent` recursive foreign key field to effect hierarchical ordering
+ * Added a `comments` field
+* dcim.Location
+ * Added a `comments` field
+* dcim.ModuleType
+ * Added the optional `profile` foreign key to the new ModuleTypeProfile model
+* dcim.PowerOutlet
+ * Added a `status` field
+* dcim.Rack
+ * Added the optional `outer_height` field
+* dcim.RackType
+ * Added the optional `outer_height` field
+* dcim.Region
+ * Added a `comments` field
+* dcim.SiteGroup
+ * Added a `comments` field
+* extras.ConfigTemplate
+ * Added optional fields `mime_type`, `file_name`, `file_extension` and `as_attachment`
+* extras.ExportTemplate
+ * Added optional fields `file_name` and `environment_params` (JSON)
+* extras.Tag
+ * Added a `weight` field
+* ipam.IPRange
+ * Added a `mark_populaed` boolean field
+* ipam.L2VPN
+ * Added a `status` field
+* ipam.Service
+ * Removed the `device` and `virtual_machine` foreign key fields
+ * Added the `parent_object_type`, `parent_object_id`, and (read-only) `parent` fields
+* ipam.VLANGroup
+ * Added the optional `tenant` foreign key field
+* tenancy.Contact
+ * Removed the `group` foreign key field
+ * Added the `groups` many-to-many field
+* tenancy.ContactGroup
+ * Added a `comments` field
+* tenancy.TenantGroup
+ * Added a `comments` field
+* wireless.WirelessLANGroup
+ * Added a `comments` field
diff --git a/mkdocs.yml b/mkdocs.yml
index 75020d122..27526bd26 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -49,6 +49,7 @@ markdown_extensions:
- admonition
- attr_list
- footnotes
+ - md_in_html
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
@@ -147,7 +148,6 @@ nav:
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Jobs: 'plugins/development/background-jobs.md'
- Dashboard Widgets: 'plugins/development/dashboard-widgets.md'
- - Staged Changes: 'plugins/development/staged-changes.md'
- Exceptions: 'plugins/development/exceptions.md'
- Migrating to v4.0: 'plugins/development/migration-v4.md'
- Administration:
@@ -203,6 +203,7 @@ nav:
- ModuleBay: 'models/dcim/modulebay.md'
- ModuleBayTemplate: 'models/dcim/modulebaytemplate.md'
- ModuleType: 'models/dcim/moduletype.md'
+ - ModuleTypeProfile: 'models/dcim/moduletypeprofile.md'
- Platform: 'models/dcim/platform.md'
- PowerFeed: 'models/dcim/powerfeed.md'
- PowerOutlet: 'models/dcim/poweroutlet.md'
@@ -223,7 +224,6 @@ nav:
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
- Extras:
- Bookmark: 'models/extras/bookmark.md'
- - Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md'
@@ -236,8 +236,8 @@ nav:
- Notification: 'models/extras/notification.md'
- NotificationGroup: 'models/extras/notificationgroup.md'
- SavedFilter: 'models/extras/savedfilter.md'
- - StagedChange: 'models/extras/stagedchange.md'
- Subscription: 'models/extras/subscription.md'
+ - TableConfig: 'models/extras/tableconfig.md'
- Tag: 'models/extras/tag.md'
- Webhook: 'models/extras/webhook.md'
- IPAM:
@@ -309,6 +309,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
+ - Version 4.3: 'release-notes/version-4.3.md'
- Version 4.2: 'release-notes/version-4.2.md'
- Version 4.1: 'release-notes/version-4.1.md'
- Version 4.0: 'release-notes/version-4.0.md'
diff --git a/netbox/account/migrations/0001_initial.py b/netbox/account/migrations/0001_initial.py
index 72c079565..badd459ca 100644
--- a/netbox/account/migrations/0001_initial.py
+++ b/netbox/account/migrations/0001_initial.py
@@ -8,7 +8,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('users', '0004_netboxgroup_netboxuser'),
+ ('users', '0002_squashed_0004'),
]
operations = [
diff --git a/netbox/account/views.py b/netbox/account/views.py
index 835ae81c2..f28d5eff5 100644
--- a/netbox/account/views.py
+++ b/netbox/account/views.py
@@ -12,7 +12,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
-from django.utils.http import url_has_allowed_host_and_scheme, urlencode
+from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
@@ -28,6 +28,7 @@ from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
+from utilities.request import safe_for_redirect
from utilities.string import remove_linebreaks
from utilities.views import register_model_view
@@ -90,10 +91,12 @@ class LoginView(View):
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
+ login_form_hidden = settings.LOGIN_FORM_HIDDEN
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
+ 'login_form_hidden': login_form_hidden,
})
def post(self, request):
@@ -146,7 +149,7 @@ class LoginView(View):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
- if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
+ if redirect_url and safe_for_redirect(redirect_url):
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
else:
if redirect_url:
diff --git a/netbox/circuits/graphql/enums.py b/netbox/circuits/graphql/enums.py
new file mode 100644
index 000000000..c99fbda95
--- /dev/null
+++ b/netbox/circuits/graphql/enums.py
@@ -0,0 +1,16 @@
+import strawberry
+
+from circuits.choices import *
+
+__all__ = (
+ 'CircuitStatusEnum',
+ 'CircuitTerminationSideEnum',
+ 'CircuitPriorityEnum',
+ 'VirtualCircuitTerminationRoleEnum',
+)
+
+
+CircuitPriorityEnum = strawberry.enum(CircuitPriorityChoices.as_enum(prefix='priority'))
+CircuitStatusEnum = strawberry.enum(CircuitStatusChoices.as_enum('status'))
+CircuitTerminationSideEnum = strawberry.enum(CircuitTerminationSideChoices.as_enum(prefix='side'))
+VirtualCircuitTerminationRoleEnum = strawberry.enum(VirtualCircuitTerminationRoleChoices.as_enum(prefix='role'))
diff --git a/netbox/circuits/graphql/filter_mixins.py b/netbox/circuits/graphql/filter_mixins.py
new file mode 100644
index 000000000..3ae6fa82e
--- /dev/null
+++ b/netbox/circuits/graphql/filter_mixins.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+
+from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.enums import ColorEnum
+
+__all__ = (
+ 'BaseCircuitTypeFilterMixin',
+)
+
+
+@dataclass
+class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin):
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py
index 7d066f428..966849fd0 100644
--- a/netbox/circuits/graphql/filters.py
+++ b/netbox/circuits/graphql/filters.py
@@ -1,7 +1,30 @@
-import strawberry_django
+from datetime import date
+from typing import Annotated, TYPE_CHECKING
-from circuits import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import strawberry
+import strawberry_django
+from strawberry.scalars import ID
+from strawberry_django import FilterLookup, DateFilterLookup
+
+from circuits import models
+from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
+from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
+from netbox.graphql.filter_mixins import (
+ DistanceFilterMixin,
+ ImageAttachmentFilterMixin,
+ OrganizationalModelFilterMixin,
+ PrimaryModelFilterMixin,
+)
+from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
+from .filter_mixins import BaseCircuitTypeFilterMixin
+
+if TYPE_CHECKING:
+ from core.graphql.filters import ContentTypeFilter
+ from dcim.graphql.filters import InterfaceFilter, LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
+ from ipam.graphql.filters import ASNFilter
+ from netbox.graphql.filter_lookups import IntegerLookup
+ from .enums import *
__all__ = (
'CircuitFilter',
@@ -19,66 +42,183 @@ __all__ = (
@strawberry_django.filter(models.CircuitTermination, lookups=True)
-@autotype_decorator(filtersets.CircuitTerminationFilterSet)
-class CircuitTerminationFilter(BaseFilterMixin):
- pass
+class CircuitTerminationFilter(
+ BaseObjectTypeFilterMixin,
+ CustomFieldsFilterMixin,
+ TagsFilterMixin,
+ ChangeLogFilterMixin,
+ CabledObjectModelFilterMixin,
+):
+ circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ termination_id: ID | None = strawberry_django.filter_field()
+ port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ pp_info: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+
+ # Cached relations
+ _provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field(name='provider_network')
+ )
+ _location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field(name='location')
+ )
+ _region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field(name='region')
+ )
+ _site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field(name='site_group')
+ )
+ _site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field(name='site')
+ )
@strawberry_django.filter(models.Circuit, lookups=True)
-@autotype_decorator(filtersets.CircuitFilterSet)
-class CircuitFilter(BaseFilterMixin):
- pass
+class CircuitFilter(
+ ContactFilterMixin,
+ ImageAttachmentFilterMixin,
+ DistanceFilterMixin,
+ TenancyFilterMixin,
+ PrimaryModelFilterMixin
+):
+ cid: FilterLookup[str] | None = strawberry_django.filter_field()
+ provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_id: ID | None = strawberry_django.filter_field()
+ provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_account_id: ID | None = strawberry_django.filter_field()
+ type: Annotated['CircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ type_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ install_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
+ termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field()
+ commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ terminations: Annotated['CircuitTerminationFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.CircuitType, lookups=True)
-@autotype_decorator(filtersets.CircuitTypeFilterSet)
-class CircuitTypeFilter(BaseFilterMixin):
+class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroup, lookups=True)
-@autotype_decorator(filtersets.CircuitGroupFilterSet)
-class CircuitGroupFilter(BaseFilterMixin):
+class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.CircuitGroupAssignment, lookups=True)
-@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet)
-class CircuitGroupAssignmentFilter(BaseFilterMixin):
- pass
+class CircuitGroupAssignmentFilter(
+ BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
+):
+ member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ member_id: ID | None = strawberry_django.filter_field()
+ group: Annotated['CircuitGroupFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: ID | None = strawberry_django.filter_field()
+ priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Provider, lookups=True)
-@autotype_decorator(filtersets.ProviderFilterSet)
-class ProviderFilter(BaseFilterMixin):
- pass
+class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ProviderAccount, lookups=True)
-@autotype_decorator(filtersets.ProviderAccountFilterSet)
-class ProviderAccountFilter(BaseFilterMixin):
- pass
+class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+ provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_id: ID | None = strawberry_django.filter_field()
+ account: FilterLookup[str] | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ProviderNetwork, lookups=True)
-@autotype_decorator(filtersets.ProviderNetworkFilterSet)
-class ProviderNetworkFilter(BaseFilterMixin):
- pass
+class ProviderNetworkFilter(PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_id: ID | None = strawberry_django.filter_field()
+ service_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualCircuitType, lookups=True)
-@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet)
-class VirtualCircuitTypeFilter(BaseFilterMixin):
+class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
pass
@strawberry_django.filter(models.VirtualCircuit, lookups=True)
-@autotype_decorator(filtersets.VirtualCircuitFilterSet)
-class VirtualCircuitFilter(BaseFilterMixin):
- pass
+class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ cid: FilterLookup[str] | None = strawberry_django.filter_field()
+ provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_network_id: ID | None = strawberry_django.filter_field()
+ provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ provider_account_id: ID | None = strawberry_django.filter_field()
+ type: Annotated['VirtualCircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ type_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
-@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet)
-class VirtualCircuitTerminationFilter(BaseFilterMixin):
- pass
+class VirtualCircuitTerminationFilter(
+ BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
+):
+ virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ virtual_circuit_id: ID | None = strawberry_django.filter_field()
+ role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ interface_id: ID | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py
index 96fcaa144..89d2a33b6 100644
--- a/netbox/circuits/graphql/types.py
+++ b/netbox/circuits/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List, Union
+from typing import Annotated, List, TYPE_CHECKING, Union
import strawberry
import strawberry_django
@@ -10,11 +10,15 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, O
from tenancy.graphql.types import TenantType
from .filters import *
+if TYPE_CHECKING:
+ from dcim.graphql.types import InterfaceType, LocationType, RegionType, SiteGroupType, SiteType
+ from ipam.graphql.types import ASNType
+
__all__ = (
- 'CircuitTerminationType',
- 'CircuitType',
'CircuitGroupAssignmentType',
'CircuitGroupType',
+ 'CircuitTerminationType',
+ 'CircuitType',
'CircuitTypeType',
'ProviderType',
'ProviderAccountType',
@@ -28,7 +32,8 @@ __all__ = (
@strawberry_django.type(
models.Provider,
fields='__all__',
- filters=ProviderFilter
+ filters=ProviderFilter,
+ pagination=True
)
class ProviderType(NetBoxObjectType, ContactsMixin):
@@ -41,7 +46,8 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
@strawberry_django.type(
models.ProviderAccount,
fields='__all__',
- filters=ProviderAccountFilter
+ filters=ProviderAccountFilter,
+ pagination=True
)
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
@@ -52,7 +58,8 @@ class ProviderAccountType(ContactsMixin, NetBoxObjectType):
@strawberry_django.type(
models.ProviderNetwork,
fields='__all__',
- filters=ProviderNetworkFilter
+ filters=ProviderNetworkFilter,
+ pagination=True
)
class ProviderNetworkType(NetBoxObjectType):
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
@@ -62,8 +69,9 @@ class ProviderNetworkType(NetBoxObjectType):
@strawberry_django.type(
models.CircuitTermination,
- exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'),
- filters=CircuitTerminationFilter
+ exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'],
+ filters=CircuitTerminationFilter,
+ pagination=True
)
class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
@@ -82,7 +90,8 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
@strawberry_django.type(
models.CircuitType,
fields='__all__',
- filters=CircuitTypeFilter
+ filters=CircuitTypeFilter,
+ pagination=True
)
class CircuitTypeType(OrganizationalObjectType):
color: str
@@ -93,7 +102,8 @@ class CircuitTypeType(OrganizationalObjectType):
@strawberry_django.type(
models.Circuit,
fields='__all__',
- filters=CircuitFilter
+ filters=CircuitFilter,
+ pagination=True
)
class CircuitType(NetBoxObjectType, ContactsMixin):
provider: ProviderType
@@ -109,7 +119,8 @@ class CircuitType(NetBoxObjectType, ContactsMixin):
@strawberry_django.type(
models.CircuitGroup,
fields='__all__',
- filters=CircuitGroupFilter
+ filters=CircuitGroupFilter,
+ pagination=True
)
class CircuitGroupType(OrganizationalObjectType):
tenant: TenantType | None
@@ -117,8 +128,9 @@ class CircuitGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.CircuitGroupAssignment,
- exclude=('member_type', 'member_id'),
- filters=CircuitGroupAssignmentFilter
+ exclude=['member_type', 'member_id'],
+ filters=CircuitGroupAssignmentFilter,
+ pagination=True
)
class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
@@ -134,7 +146,8 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
@strawberry_django.type(
models.VirtualCircuitType,
fields='__all__',
- filters=VirtualCircuitTypeFilter
+ filters=VirtualCircuitTypeFilter,
+ pagination=True
)
class VirtualCircuitTypeType(OrganizationalObjectType):
color: str
@@ -145,7 +158,8 @@ class VirtualCircuitTypeType(OrganizationalObjectType):
@strawberry_django.type(
models.VirtualCircuitTermination,
fields='__all__',
- filters=VirtualCircuitTerminationFilter
+ filters=VirtualCircuitTerminationFilter,
+ pagination=True
)
class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
virtual_circuit: Annotated[
@@ -161,7 +175,8 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
@strawberry_django.type(
models.VirtualCircuit,
fields='__all__',
- filters=VirtualCircuitFilter
+ filters=VirtualCircuitFilter,
+ pagination=True
)
class VirtualCircuitType(NetBoxObjectType):
provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
diff --git a/netbox/circuits/migrations/0002_squashed_0029.py b/netbox/circuits/migrations/0002_squashed_0029.py
index cb61d8feb..0062575cd 100644
--- a/netbox/circuits/migrations/0002_squashed_0029.py
+++ b/netbox/circuits/migrations/0002_squashed_0029.py
@@ -5,11 +5,11 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0001_initial'),
+ ('dcim', '0001_squashed'),
('contenttypes', '0002_remove_content_type_name'),
- ('circuits', '0001_initial'),
- ('extras', '0001_initial'),
- ('tenancy', '0001_initial'),
+ ('circuits', '0001_squashed'),
+ ('extras', '0001_squashed'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
diff --git a/netbox/circuits/migrations/0038_squashed_0042.py b/netbox/circuits/migrations/0038_squashed_0042.py
index fa944b763..be07638b4 100644
--- a/netbox/circuits/migrations/0038_squashed_0042.py
+++ b/netbox/circuits/migrations/0038_squashed_0042.py
@@ -15,8 +15,8 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('circuits', '0037_new_cabling_models'),
- ('dcim', '0160_populate_cable_ends'),
+ ('circuits', '0003_squashed_0037'),
+ ('dcim', '0160_squashed_0166'),
]
operations = [
diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py
index 6c4dffeb6..400c419ef 100644
--- a/netbox/circuits/migrations/0043_circuittype_color.py
+++ b/netbox/circuits/migrations/0043_circuittype_color.py
@@ -6,7 +6,7 @@ import utilities.fields
class Migration(migrations.Migration):
dependencies = [
- ('circuits', '0042_provideraccount'),
+ ('circuits', '0038_squashed_0042'),
]
operations = [
diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py
index f78e17ec3..4caa3a37d 100644
--- a/netbox/circuits/migrations/0047_circuittermination__termination.py
+++ b/netbox/circuits/migrations/0047_circuittermination__termination.py
@@ -39,9 +39,6 @@ class Migration(migrations.Migration):
name='termination_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- ('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork'))
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py
index f8c0fd653..0418c26e5 100644
--- a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py
+++ b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py
@@ -51,7 +51,6 @@ class Migration(migrations.Migration):
name='member_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
- limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
related_name='+',
to='contenttypes.contenttype',
blank=True,
@@ -68,7 +67,6 @@ class Migration(migrations.Migration):
model_name='circuitgroupassignment',
name='member_type',
field=models.ForeignKey(
- limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index 8f5df7eb1..65f6323ae 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -182,7 +182,6 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
"""
member_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
@@ -249,7 +248,6 @@ class CircuitTermination(
termination_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
- limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
related_name='+',
blank=True,
null=True
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 8d5114ed9..62056cfbe 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -6,7 +6,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView
from ipam.models import ASN
from netbox.views import generic
-from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
@@ -89,11 +88,6 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable
-@register_model_view(Provider, 'contacts')
-class ProviderContactsView(ObjectContactsView):
- queryset = Provider.objects.all()
-
-
#
# ProviderAccounts
#
@@ -156,11 +150,6 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderAccountTable
-@register_model_view(ProviderAccount, 'contacts')
-class ProviderAccountContactsView(ObjectContactsView):
- queryset = ProviderAccount.objects.all()
-
-
#
# Provider networks
#
@@ -433,11 +422,6 @@ class CircuitSwapTerminations(generic.ObjectEditView):
})
-@register_model_view(Circuit, 'contacts')
-class CircuitContactsView(ObjectContactsView):
- queryset = Circuit.objects.all()
-
-
#
# Circuit terminations
#
diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py
index 2c155ba6b..3f2ddb2a0 100644
--- a/netbox/core/api/serializers_/data.py
+++ b/netbox/core/api/serializers_/data.py
@@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer):
model = DataSource
fields = [
'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description',
- 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced',
- 'file_count',
+ 'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated',
+ 'last_synced', 'file_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
diff --git a/netbox/core/apps.py b/netbox/core/apps.py
index 283eed448..c081fb064 100644
--- a/netbox/core/apps.py
+++ b/netbox/core/apps.py
@@ -22,6 +22,7 @@ class CoreConfig(AppConfig):
def ready(self):
from core.api import schema # noqa: F401
+ from core.checks import check_duplicate_indexes # noqa: F401
from netbox.models.features import register_models
from . import data_backends, events, search # noqa: F401
from netbox import context_managers # noqa: F401
diff --git a/netbox/core/checks.py b/netbox/core/checks.py
new file mode 100644
index 000000000..cab52a025
--- /dev/null
+++ b/netbox/core/checks.py
@@ -0,0 +1,41 @@
+from django.core.checks import Error, register, Tags
+from django.db.models import Index, UniqueConstraint
+from django.apps import apps
+
+__all__ = (
+ 'check_duplicate_indexes',
+)
+
+
+@register(Tags.models)
+def check_duplicate_indexes(app_configs, **kwargs):
+ """
+ Check for an index which is redundant to a declared unique constraint.
+ """
+ errors = []
+
+ for model in apps.get_models():
+ if not (meta := getattr(model, "_meta", None)):
+ continue
+
+ index_fields = {
+ tuple(index.fields) for index in getattr(meta, 'indexes', [])
+ if isinstance(index, Index)
+ }
+ constraint_fields = {
+ tuple(constraint.fields) for constraint in getattr(meta, 'constraints', [])
+ if isinstance(constraint, UniqueConstraint)
+ }
+
+ # Find overlapping definitions
+ if duplicated := index_fields & constraint_fields:
+ for fields in duplicated:
+ errors.append(
+ Error(
+ f"Model '{model.__name__}' defines the same field set {fields} in both `Meta.indexes` and "
+ f"`Meta.constraints`.",
+ obj=model,
+ )
+ )
+
+ return errors
diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py
index 770a3b258..9ba1d5dfd 100644
--- a/netbox/core/data_backends.py
+++ b/netbox/core/data_backends.py
@@ -7,13 +7,13 @@ from pathlib import Path
from urllib.parse import urlparse
from django import forms
-from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _
from netbox.data_backends import DataBackend
from netbox.utils import register_data_backend
from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
+from utilities.proxy import resolve_proxies
from utilities.socks import ProxyPoolManager
from .exceptions import SyncError
@@ -70,18 +70,18 @@ class GitBackend(DataBackend):
# Initialize backend config
config = ConfigDict()
- self.use_socks = False
+ self.socks_proxy = None
# Apply HTTP proxy (if configured)
- if settings.HTTP_PROXIES:
- if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None):
- if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
- raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
+ proxies = resolve_proxies(url=self.url, context={'client': self}) or {}
+ if proxy := proxies.get(self.url_scheme):
+ if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
+ raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
- if self.url_scheme in ('http', 'https'):
- config.set("http", "proxy", proxy)
- if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
- self.use_socks = True
+ if self.url_scheme in ('http', 'https'):
+ config.set("http", "proxy", proxy)
+ if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
+ self.socks_proxy = proxy
return config
@@ -98,8 +98,8 @@ class GitBackend(DataBackend):
}
# check if using socks for proxy - if so need to use custom pool_manager
- if self.use_socks:
- clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme))
+ if self.socks_proxy:
+ clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
if self.url_scheme in ('http', 'https'):
if self.params.get('username'):
@@ -147,7 +147,7 @@ class S3Backend(DataBackend):
# Initialize backend config
return Boto3Config(
- proxies=settings.HTTP_PROXIES,
+ proxies=resolve_proxies(url=self.url, context={'client': self}),
)
@contextmanager
diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py
index 8412b0378..5790704c2 100644
--- a/netbox/core/exceptions.py
+++ b/netbox/core/exceptions.py
@@ -1,2 +1,9 @@
+from django.core.exceptions import ImproperlyConfigured
+
+
class SyncError(Exception):
pass
+
+
+class IncompatiblePluginError(ImproperlyConfigured):
+ pass
diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py
index 21fdaa4ab..42ec22350 100644
--- a/netbox/core/filtersets.py
+++ b/netbox/core/filtersets.py
@@ -29,6 +29,10 @@ class DataSourceFilterSet(NetBoxModelFilterSet):
choices=DataSourceStatusChoices,
null_value=None
)
+ sync_interval = django_filters.MultipleChoiceFilter(
+ choices=JobIntervalChoices,
+ null_value=None
+ )
class Meta:
model = DataSource
diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py
index c1f1fca4d..73618826d 100644
--- a/netbox/core/forms/bulk_edit.py
+++ b/netbox/core/forms/bulk_edit.py
@@ -1,6 +1,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
+from core.choices import JobIntervalChoices
from core.models import *
from netbox.forms import NetBoxModelBulkEditForm
from netbox.utils import get_data_backend_choices
@@ -29,6 +30,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ sync_interval = forms.ChoiceField(
+ choices=JobIntervalChoices,
+ required=False,
+ label=_('Sync interval')
+ )
comments = CommentField()
parameters = forms.JSONField(
label=_('Parameters'),
@@ -42,8 +48,8 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
model = DataSource
fieldsets = (
- FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'),
+ FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'),
)
nullable_fields = (
- 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules',
+ 'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments',
)
diff --git a/netbox/core/forms/bulk_import.py b/netbox/core/forms/bulk_import.py
index 78a859dcb..a5791c945 100644
--- a/netbox/core/forms/bulk_import.py
+++ b/netbox/core/forms/bulk_import.py
@@ -11,5 +11,6 @@ class DataSourceImportForm(NetBoxModelImportForm):
class Meta:
model = DataSource
fields = (
- 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules',
+ 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules',
+ 'comments',
)
diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py
index f9564a76f..0f25932e0 100644
--- a/netbox/core/forms/filtersets.py
+++ b/netbox/core/forms/filtersets.py
@@ -27,7 +27,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
model = DataSource
fieldsets = (
FieldSet('q', 'filter_id'),
- FieldSet('type', 'status', name=_('Data Source')),
+ FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
@@ -46,6 +46,11 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
+ sync_interval = forms.ChoiceField(
+ label=_('Sync interval'),
+ choices=JobIntervalChoices,
+ required=False
+ )
class DataFileFilterForm(NetBoxModelFilterSetForm):
diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py
index a05377597..0a683a381 100644
--- a/netbox/core/forms/model_forms.py
+++ b/netbox/core/forms/model_forms.py
@@ -36,7 +36,7 @@ class DataSourceForm(NetBoxModelForm):
class Meta:
model = DataSource
fields = [
- 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
+ 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags',
]
widgets = {
'ignore_rules': forms.Textarea(
@@ -51,7 +51,10 @@ class DataSourceForm(NetBoxModelForm):
@property
def fieldsets(self):
fieldsets = [
- FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')),
+ FieldSet(
+ 'name', 'type', 'source_url', 'description', 'tags', 'ignore_rules', name=_('Source')
+ ),
+ FieldSet('enabled', 'sync_interval', name=_('Sync')),
]
if self.backend_fields:
fieldsets.append(
diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py
new file mode 100644
index 000000000..670ec2ebb
--- /dev/null
+++ b/netbox/core/graphql/filter_mixins.py
@@ -0,0 +1,36 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+from strawberry import ID
+from strawberry_django import DatetimeFilterLookup
+
+if TYPE_CHECKING:
+ from .filters import *
+
+__all__ = (
+ 'BaseFilterMixin',
+ 'BaseObjectTypeFilterMixin',
+ 'ChangeLogFilterMixin',
+)
+
+
+# @strawberry.input
+class BaseFilterMixin: ...
+
+
+@dataclass
+class BaseObjectTypeFilterMixin(BaseFilterMixin):
+ id: ID | None = strawberry.UNSET
+
+
+@dataclass
+class ChangeLogFilterMixin(BaseFilterMixin):
+ id: ID | None = strawberry.UNSET
+ changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+ last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py
index 82da685a5..e5d44674a 100644
--- a/netbox/core/graphql/filters.py
+++ b/netbox/core/graphql/filters.py
@@ -1,28 +1,89 @@
-import strawberry_django
+from datetime import datetime
+from typing import Annotated, TYPE_CHECKING
-from core import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import strawberry
+import strawberry_django
+from django.contrib.contenttypes.models import ContentType as DjangoContentType
+from strawberry.scalars import ID
+from strawberry_django import DatetimeFilterLookup, FilterLookup
+
+from core import models
+from core.graphql.filter_mixins import BaseFilterMixin
+from netbox.graphql.filter_mixins import PrimaryModelFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter
+ from users.graphql.filters import UserFilter
__all__ = (
'DataFileFilter',
'DataSourceFilter',
'ObjectChangeFilter',
+ 'ContentTypeFilter',
)
@strawberry_django.filter(models.DataFile, lookups=True)
-@autotype_decorator(filtersets.DataFileFilterSet)
class DataFileFilter(BaseFilterMixin):
- pass
+ id: ID | None = strawberry_django.filter_field()
+ created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+ last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+ source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ source_id: ID | None = strawberry_django.filter_field()
+ path: FilterLookup[str] | None = strawberry_django.filter_field()
+ size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ hash: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DataSource, lookups=True)
-@autotype_decorator(filtersets.DataSourceFilterSet)
-class DataSourceFilter(BaseFilterMixin):
- pass
+class DataSourceFilter(PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ type: FilterLookup[str] | None = strawberry_django.filter_field()
+ source_url: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: FilterLookup[str] | None = strawberry_django.filter_field()
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field()
+ parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+ datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ObjectChange, lookups=True)
-@autotype_decorator(filtersets.ObjectChangeFilterSet)
class ObjectChangeFilter(BaseFilterMixin):
- pass
+ id: ID | None = strawberry_django.filter_field()
+ time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+ user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+ user_name: FilterLookup[str] | None = strawberry_django.filter_field()
+ request_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ action: FilterLookup[str] | None = strawberry_django.filter_field()
+ changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ changed_object_type_id: ID | None = strawberry_django.filter_field()
+ changed_object_id: ID | None = strawberry_django.filter_field()
+ related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ related_object_id: ID | None = strawberry_django.filter_field()
+ object_repr: FilterLookup[str] | None = strawberry_django.filter_field()
+ prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@strawberry_django.filter(DjangoContentType, lookups=True)
+class ContentTypeFilter(BaseFilterMixin):
+ id: ID | None = strawberry_django.filter_field()
+ app_label: FilterLookup[str] | None = strawberry_django.filter_field()
+ model: FilterLookup[str] | None = strawberry_django.filter_field()
diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py
index 5195b52a0..72191e6fd 100644
--- a/netbox/core/graphql/mixins.py
+++ b/netbox/core/graphql/mixins.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, TYPE_CHECKING
import strawberry
import strawberry_django
@@ -6,6 +6,9 @@ from django.contrib.contenttypes.models import ContentType
from core.models import ObjectChange
+if TYPE_CHECKING:
+ from netbox.core.graphql.types import ObjectChangeType
+
__all__ = (
'ChangelogMixin',
)
diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py
index 09385d7c1..ffaa24411 100644
--- a/netbox/core/graphql/types.py
+++ b/netbox/core/graphql/types.py
@@ -2,12 +2,14 @@ from typing import Annotated, List
import strawberry
import strawberry_django
+from django.contrib.contenttypes.models import ContentType as DjangoContentType
from core import models
from netbox.graphql.types import BaseObjectType, NetBoxObjectType
from .filters import *
__all__ = (
+ 'ContentType',
'DataFileType',
'DataSourceType',
'ObjectChangeType',
@@ -17,7 +19,8 @@ __all__ = (
@strawberry_django.type(
models.DataFile,
exclude=['data',],
- filters=DataFileFilter
+ filters=DataFileFilter,
+ pagination=True
)
class DataFileType(BaseObjectType):
source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')]
@@ -26,7 +29,8 @@ class DataFileType(BaseObjectType):
@strawberry_django.type(
models.DataSource,
fields='__all__',
- filters=DataSourceFilter
+ filters=DataSourceFilter,
+ pagination=True
)
class DataSourceType(NetBoxObjectType):
@@ -36,7 +40,17 @@ class DataSourceType(NetBoxObjectType):
@strawberry_django.type(
models.ObjectChange,
fields='__all__',
- filters=ObjectChangeFilter
+ filters=ObjectChangeFilter,
+ pagination=True
)
class ObjectChangeType(BaseObjectType):
pass
+
+
+@strawberry_django.type(
+ DjangoContentType,
+ fields='__all__',
+ pagination=True
+)
+class ContentType:
+ pass
diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py
index 891b1cbdb..b3dfaf1e7 100644
--- a/netbox/core/jobs.py
+++ b/netbox/core/jobs.py
@@ -5,6 +5,7 @@ import sys
from django.conf import settings
from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend
+from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices
from .exceptions import SyncError
from .models import DataSource
@@ -71,7 +72,7 @@ class SystemHousekeepingJob(JobRunner):
url=settings.CENSUS_URL,
params=census_data,
timeout=3,
- proxies=settings.HTTP_PROXIES
+ proxies=resolve_proxies(url=settings.CENSUS_URL)
)
except requests.exceptions.RequestException:
pass
diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py
index 7c9914298..6a7fe2521 100644
--- a/netbox/core/migrations/0006_datasource_type_remove_choices.py
+++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('core', '0005_job_created_auto_now'),
+ ('core', '0001_squashed_0005'),
]
operations = [
diff --git a/netbox/core/migrations/0014_datasource_sync_interval.py b/netbox/core/migrations/0014_datasource_sync_interval.py
new file mode 100644
index 000000000..4b2e3ddd7
--- /dev/null
+++ b/netbox/core/migrations/0014_datasource_sync_interval.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0013_job_data_encoder'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='datasource',
+ name='sync_interval',
+ field=models.PositiveSmallIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/core/migrations/0015_remove_redundant_indexes.py b/netbox/core/migrations/0015_remove_redundant_indexes.py
new file mode 100644
index 000000000..b5ff0db6e
--- /dev/null
+++ b/netbox/core/migrations/0015_remove_redundant_indexes.py
@@ -0,0 +1,23 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0014_datasource_sync_interval'),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name='autosyncrecord',
+ name='core_autosy_object__c17bac_idx',
+ ),
+ migrations.RemoveIndex(
+ model_name='datafile',
+ name='core_datafile_source_path',
+ ),
+ migrations.RemoveIndex(
+ model_name='managedfile',
+ name='core_managedfile_root_path',
+ ),
+ ]
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 39ee8fa57..52a11c58e 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -59,6 +59,12 @@ class DataSource(JobsMixin, PrimaryModel):
verbose_name=_('enabled'),
default=True
)
+ sync_interval = models.PositiveSmallIntegerField(
+ verbose_name=_('sync interval'),
+ choices=JobIntervalChoices,
+ blank=True,
+ null=True
+ )
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
@@ -304,9 +310,6 @@ class DataFile(models.Model):
name='%(app_label)s_%(class)s_unique_source_path'
),
)
- indexes = [
- models.Index(fields=('source', 'path'), name='core_datafile_source_path'),
- ]
verbose_name = _('data file')
verbose_name_plural = _('data files')
@@ -351,17 +354,6 @@ class DataFile(models.Model):
return is_modified
- def write_to_disk(self, path, overwrite=False):
- """
- Write the object's data to disk at the specified path
- """
- # Check whether file already exists
- if os.path.isfile(path) and not overwrite:
- raise FileExistsError()
-
- with open(path, 'wb+') as new_file:
- new_file.write(self.data)
-
class AutoSyncRecord(models.Model):
"""
@@ -392,8 +384,5 @@ class AutoSyncRecord(models.Model):
name='%(app_label)s_%(class)s_object'
),
)
- indexes = (
- models.Index(fields=('object_type', 'object_id')),
- )
verbose_name = _('auto sync record')
verbose_name_plural = _('auto sync records')
diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py
index cc446bac7..d60269b8b 100644
--- a/netbox/core/models/files.py
+++ b/netbox/core/models/files.py
@@ -1,13 +1,16 @@
import logging
import os
+from functools import cached_property
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
+from django.core.files.storage import storages
from django.urls import reverse
from django.utils.translation import gettext as _
from ..choices import ManagedFileRootPathChoices
+from extras.storage import ScriptFileSystemStorage
from netbox.models.features import SyncedDataMixin
from utilities.querysets import RestrictedQuerySet
@@ -55,9 +58,6 @@ class ManagedFile(SyncedDataMixin, models.Model):
name='%(app_label)s_%(class)s_unique_root_path'
),
)
- indexes = [
- models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
- ]
verbose_name = _('managed file')
verbose_name_plural = _('managed files')
@@ -76,15 +76,35 @@ class ManagedFile(SyncedDataMixin, models.Model):
return os.path.join(self._resolve_root_path(), self.file_path)
def _resolve_root_path(self):
- return {
- 'scripts': settings.SCRIPTS_ROOT,
- 'reports': settings.REPORTS_ROOT,
- }[self.file_root]
+ storage = self.storage
+ if isinstance(storage, ScriptFileSystemStorage):
+ return {
+ 'scripts': settings.SCRIPTS_ROOT,
+ 'reports': settings.REPORTS_ROOT,
+ }[self.file_root]
+ else:
+ return ""
def sync_data(self):
if self.data_file:
self.file_path = os.path.basename(self.data_path)
- self.data_file.write_to_disk(self.full_path, overwrite=True)
+ self._write_to_disk(self.full_path, overwrite=True)
+
+ def _write_to_disk(self, path, overwrite=False):
+ """
+ Write the object's data to disk at the specified path
+ """
+ # Check whether file already exists
+ storage = self.storage
+ if storage.exists(path) and not overwrite:
+ raise FileExistsError()
+
+ with storage.open(path, 'wb+') as new_file:
+ new_file.write(self.data)
+
+ @cached_property
+ def storage(self):
+ return storages.create_storage(storages.backends["scripts"])
def clean(self):
super().clean()
@@ -104,8 +124,9 @@ class ManagedFile(SyncedDataMixin, models.Model):
def delete(self, *args, **kwargs):
# Delete file from disk
+ storage = self.storage
try:
- os.remove(self.full_path)
+ storage.delete(self.full_path)
except FileNotFoundError:
pass
diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py
index 66580c936..0a5bd8fe3 100644
--- a/netbox/core/plugins.py
+++ b/netbox/core/plugins.py
@@ -9,7 +9,9 @@ from django.conf import settings
from django.core.cache import cache
from netbox.plugins import PluginConfig
+from netbox.registry import registry
from utilities.datetime import datetime_from_timestamp
+from utilities.proxy import resolve_proxies
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed'
@@ -47,6 +49,7 @@ class Plugin:
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
+ icon_url: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
@@ -63,9 +66,11 @@ class Plugin:
is_certified: bool = False
release_latest: PluginVersion = field(default_factory=PluginVersion)
release_recent_history: list[PluginVersion] = field(default_factory=list)
- is_local: bool = False # extra field for locally installed plugins
- is_installed: bool = False
+ is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed)
+ is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch
installed_version: str = ''
+ netbox_min_version: str = ''
+ netbox_max_version: str = ''
def get_local_plugins(plugins=None):
@@ -79,6 +84,9 @@ def get_local_plugins(plugins=None):
for plugin_name in settings.PLUGINS:
plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config
+ installed_version = plugin_config.version
+ if plugin_config.release_track:
+ installed_version = f'{installed_version}-{plugin_config.release_track}'
if plugin_config.author:
author = PluginAuthor(
@@ -94,20 +102,29 @@ def get_local_plugins(plugins=None):
tag_line=plugin_config.description,
description_short=plugin_config.description,
is_local=True,
- is_installed=True,
+ is_loaded=plugin_name in registry['plugins']['installed'],
+ installed_version=installed_version,
+ netbox_min_version=plugin_config.min_version,
+ netbox_max_version=plugin_config.max_version,
author=author,
- installed_version=plugin_config.version,
)
# Update catalog entries for local plugins, or add them to the list if not listed
for k, v in local_plugins.items():
if k in plugins:
- plugins[k].is_local = True
- plugins[k].is_installed = True
+ plugins[k].is_local = v.is_local
+ plugins[k].is_loaded = v.is_loaded
plugins[k].installed_version = v.installed_version
else:
plugins[k] = v
+ # Update plugin table config for hidden and static plugins
+ hidden = settings.PLUGINS_CATALOG_CONFIG.get('hidden', [])
+ static = settings.PLUGINS_CATALOG_CONFIG.get('static', [])
+ for k, v in plugins.items():
+ v.hidden = k in hidden
+ v.static = k in static
+
return plugins
@@ -124,10 +141,11 @@ def get_catalog_plugins():
def get_pages():
# TODO: pagination is currently broken in API
payload = {'page': '1', 'per_page': '50'}
+ proxies = resolve_proxies(url=settings.PLUGIN_CATALOG_URL)
first_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
- proxies=settings.HTTP_PROXIES,
+ proxies=proxies,
timeout=3,
params=payload
).json()
@@ -139,7 +157,7 @@ def get_catalog_plugins():
next_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
- proxies=settings.HTTP_PROXIES,
+ proxies=proxies,
timeout=3,
params=payload
).json()
@@ -193,6 +211,7 @@ def get_catalog_plugins():
# Populate plugin data
plugins[data['config_name']] = Plugin(
id=data['id'],
+ icon_url=data['icon'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],
diff --git a/netbox/core/signals.py b/netbox/core/signals.py
index 0bed3fd45..4b537b2d4 100644
--- a/netbox/core/signals.py
+++ b/netbox/core/signals.py
@@ -8,16 +8,15 @@ from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
-from core.choices import ObjectChangeActionChoices
+from core.choices import JobStatusChoices, ObjectChangeActionChoices
from core.events import *
-from core.models import ObjectChange
from extras.events import enqueue_event
from extras.utils import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from utilities.exceptions import AbortRequest
-from .models import ConfigRevision
+from .models import ConfigRevision, DataSource, ObjectChange
__all__ = (
'clear_events',
@@ -188,6 +187,25 @@ def clear_events_queue(sender, **kwargs):
# DataSource handlers
#
+@receiver(post_save, sender=DataSource)
+def enqueue_sync_job(instance, created, **kwargs):
+ """
+ When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate.
+ """
+ from .jobs import SyncDataSourceJob
+
+ if instance.enabled and instance.sync_interval:
+ SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval)
+ elif not created:
+ # Delete any previously scheduled recurring jobs for this DataSource
+ for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter(
+ interval__isnull=False,
+ status=JobStatusChoices.STATUS_SCHEDULED
+ ):
+ # Call delete() per instance to ensure the associated background task is deleted as well
+ job.delete()
+
+
@receiver(post_sync)
def auto_sync(instance, **kwargs):
"""
diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py
index 4059ea9bc..5c6ccebcf 100644
--- a/netbox/core/tables/data.py
+++ b/netbox/core/tables/data.py
@@ -14,10 +14,10 @@ __all__ = (
class DataSourceTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
- linkify=True
+ linkify=True,
)
type = BackendTypeColumn(
- verbose_name=_('Type')
+ verbose_name=_('Type'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
@@ -25,20 +25,26 @@ class DataSourceTable(NetBoxTable):
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
- tags = columns.TagColumn(
- url_name='core:datasource_list'
+ sync_interval = columns.ChoiceFieldColumn(
+ verbose_name=_('Sync interval'),
+ )
+ last_synced = tables.DateTimeColumn(
+ verbose_name=_('Last Synced'),
)
file_count = tables.Column(
- verbose_name='Files'
+ verbose_name=_('Files'),
+ )
+ tags = columns.TagColumn(
+ url_name='core:datasource_list',
)
class Meta(NetBoxTable.Meta):
model = DataSource
fields = (
- 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
- 'created', 'last_updated', 'file_count',
+ 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments',
+ 'parameters', 'last_synced', 'created', 'last_updated', 'file_count',
)
- default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
+ default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'sync_interval', 'file_count')
class DataFileTable(NetBoxTable):
diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py
index 96c612366..e1b80af42 100644
--- a/netbox/core/tables/plugins.py
+++ b/netbox/core/tables/plugins.py
@@ -1,7 +1,10 @@
import django_tables2 as tables
+from django.urls import reverse
+from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable, columns
+from .template_code import PLUGIN_IS_INSTALLED
__all__ = (
'CatalogPluginTable',
@@ -9,6 +12,12 @@ __all__ = (
)
+PLUGIN_NAME_TEMPLATE = """
+
+{{ record.title_long }}
+"""
+
+
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
@@ -39,8 +48,8 @@ class PluginVersionTable(BaseTable):
class CatalogPluginTable(BaseTable):
- title_long = tables.Column(
- linkify=('core:plugin', [tables.A('config_name')]),
+ title_long = columns.TemplateColumn(
+ template_code=PLUGIN_NAME_TEMPLATE,
verbose_name=_('Name')
)
author = tables.Column(
@@ -48,12 +57,15 @@ class CatalogPluginTable(BaseTable):
verbose_name=_('Author')
)
is_local = columns.BooleanColumn(
+ false_mark=None,
verbose_name=_('Local')
)
- is_installed = columns.BooleanColumn(
- verbose_name=_('Installed')
+ is_installed = columns.TemplateColumn(
+ verbose_name=_('Active'),
+ template_code=PLUGIN_IS_INSTALLED
)
is_certified = columns.BooleanColumn(
+ false_mark=None,
verbose_name=_('Certified')
)
created_at = columns.DateTimeColumn(
@@ -82,3 +94,9 @@ class CatalogPluginTable(BaseTable):
# List installed plugins first, then certified plugins, then
# everything else (with each tranche ordered alphabetically)
order_by = ('-is_installed', '-is_certified', 'name')
+
+ def render_title_long(self, value, record):
+ if record.static:
+ return value
+ url = reverse('core:plugin', args=[record.config_name])
+ return mark_safe(f"{value}")
diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py
index c8f0058e7..9fc652c4c 100644
--- a/netbox/core/tables/template_code.py
+++ b/netbox/core/tables/template_code.py
@@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """
OBJECTCHANGE_REQUEST_ID = """
{{ value }}
"""
+
+PLUGIN_IS_INSTALLED = """
+{% if record.is_local %}
+ {% if record.is_loaded %}
+
+ {% else %}
+
+ {% endif %}
+{% else %}
+ —
+{% endif %}
+"""
diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py
index 310be1d0e..b7dfd516e 100644
--- a/netbox/core/tests/test_filtersets.py
+++ b/netbox/core/tests/test_filtersets.py
@@ -27,7 +27,8 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
source_url='file:///var/tmp/source1/',
status=DataSourceStatusChoices.NEW,
enabled=True,
- description='foobar1'
+ description='foobar1',
+ sync_interval=JobIntervalChoices.INTERVAL_HOURLY
),
DataSource(
name='Data Source 2',
@@ -35,14 +36,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
source_url='file:///var/tmp/source2/',
status=DataSourceStatusChoices.SYNCING,
enabled=True,
- description='foobar2'
+ description='foobar2',
+ sync_interval=JobIntervalChoices.INTERVAL_DAILY
),
DataSource(
name='Data Source 3',
type='git',
source_url='https://example.com/git/source3',
status=DataSourceStatusChoices.COMPLETED,
- enabled=False
+ enabled=False,
+ sync_interval=JobIntervalChoices.INTERVAL_WEEKLY
),
)
DataSource.objects.bulk_create(data_sources)
@@ -73,6 +76,10 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_sync_interval(self):
+ params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = DataFile.objects.all()
diff --git a/netbox/core/views.py b/netbox/core/views.py
index 494b95cde..1264c6c1b 100644
--- a/netbox/core/views.py
+++ b/netbox/core/views.py
@@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS
+from netbox.registry import registry
from netbox.views import generic
from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin
@@ -560,7 +561,7 @@ class SystemView(UserPassesTestMixin, View):
params = [param.name for param in PARAMS]
data = {
**stats,
- 'plugins': settings.PLUGINS,
+ 'plugins': registry['plugins']['installed'],
'config': {
k: getattr(config, k) for k in sorted(params)
},
@@ -612,6 +613,8 @@ class PluginListView(BasePluginView):
if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
+ plugins = [plugin for plugin in plugins if not plugin.hidden]
+
table = CatalogPluginTable(plugins, user=request.user)
table.configure(request)
diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py
index b591030aa..468d75af9 100644
--- a/netbox/dcim/api/serializers_/device_components.py
+++ b/netbox/dcim/api/serializers_/device_components.py
@@ -152,14 +152,15 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
required=False,
allow_null=True
)
+ status = ChoiceField(choices=PowerOutletStatusChoices, required=False)
class Meta:
model = PowerOutlet
fields = [
- 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'power_port',
- 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
- 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
- 'created', 'last_updated', '_occupied',
+ 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
+ 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
+ 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
+ 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py
index 0ce2af2f8..61e3833ec 100644
--- a/netbox/dcim/api/serializers_/devicetypes.py
+++ b/netbox/dcim/api/serializers_/devicetypes.py
@@ -4,8 +4,8 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from dcim.choices import *
-from dcim.models import DeviceType, ModuleType
-from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
+from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.choices import *
from .manufacturers import ManufacturerSerializer
@@ -13,6 +13,7 @@ from .platforms import PlatformSerializer
__all__ = (
'DeviceTypeSerializer',
+ 'ModuleTypeProfileSerializer',
'ModuleTypeSerializer',
)
@@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
+class ModuleTypeProfileSerializer(NetBoxModelSerializer):
+
+ class Meta:
+ model = ModuleTypeProfile
+ fields = [
+ 'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
+ 'created', 'last_updated',
+ ]
+ brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
class ModuleTypeSerializer(NetBoxModelSerializer):
+ profile = ModuleTypeProfileSerializer(
+ nested=True,
+ required=False,
+ allow_null=True
+ )
manufacturer = ManufacturerSerializer(
nested=True
)
@@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
required=False,
allow_null=True
)
+ attributes = AttributesField(
+ source='attribute_data',
+ required=False,
+ allow_null=True
+ )
class Meta:
model = ModuleType
fields = [
- 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
- 'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
- 'created', 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
+ 'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
]
- brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
+ brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py
index ea346cc63..0e9eaa52f 100644
--- a/netbox/dcim/api/serializers_/nested.py
+++ b/netbox/dcim/api/serializers_/nested.py
@@ -52,6 +52,13 @@ class NestedLocationSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth']
+class NestedDeviceRoleSerializer(WritableNestedSerializer):
+
+ class Meta:
+ model = models.DeviceRole
+ fields = ['id', 'url', 'display_url', 'display', 'name']
+
+
class NestedDeviceSerializer(WritableNestedSerializer):
class Meta:
diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py
index 1378c265a..4bc2900dc 100644
--- a/netbox/dcim/api/serializers_/racks.py
+++ b/netbox/dcim/api/serializers_/racks.py
@@ -70,8 +70,8 @@ class RackTypeSerializer(RackBaseSerializer):
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
- 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
- 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags',
+ 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
+ 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
@@ -129,9 +129,9 @@ class RackSerializer(RackBaseSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
- 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
- 'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
- 'powerfeed_count',
+ 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
+ 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields',
+ 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py
index 8f922da10..17eeaa949 100644
--- a/netbox/dcim/api/serializers_/roles.py
+++ b/netbox/dcim/api/serializers_/roles.py
@@ -1,7 +1,8 @@
from dcim.models import DeviceRole, InventoryItemRole
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField
-from netbox.api.serializers import NetBoxModelSerializer
+from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
+from .nested import NestedDeviceRoleSerializer
__all__ = (
'DeviceRoleSerializer',
@@ -9,7 +10,8 @@ __all__ = (
)
-class DeviceRoleSerializer(NetBoxModelSerializer):
+class DeviceRoleSerializer(NestedGroupModelSerializer):
+ parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None)
config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None)
# Related object counts
@@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
class Meta:
model = DeviceRole
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template',
+ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+ 'comments', '_depth',
]
- brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
+ brief_fields = (
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth'
+ )
class InventoryItemRoleSerializer(NetBoxModelSerializer):
diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py
index b818cd954..90f7b5d35 100644
--- a/netbox/dcim/api/serializers_/sites.py
+++ b/netbox/dcim/api/serializers_/sites.py
@@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer):
model = Region
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
- 'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
+ 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
model = SiteGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
- 'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
+ 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
- 'prefix_count', '_depth',
+ 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py
index fc3740374..734ac13db 100644
--- a/netbox/dcim/api/urls.py
+++ b/netbox/dcim/api/urls.py
@@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet)
router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
router.register('module-types', views.ModuleTypeViewSet)
+router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
# Device type components
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 5ca851bca..710e55001 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -270,6 +270,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceTypeFilterSet
+class ModuleTypeProfileViewSet(NetBoxModelViewSet):
+ queryset = ModuleTypeProfile.objects.all()
+ serializer_class = serializers.ModuleTypeProfileSerializer
+ filterset_class = filtersets.ModuleTypeProfileFilterSet
+
+
class ModuleTypeViewSet(NetBoxModelViewSet):
queryset = ModuleType.objects.all()
serializer_class = serializers.ModuleTypeSerializer
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index bf405707c..267966e10 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -128,14 +128,15 @@ class RackElevationDetailRenderChoices(ChoiceSet):
class RackAirflowChoices(ChoiceSet):
+ key = 'Rack.airflow'
FRONT_TO_REAR = 'front-to-rear'
REAR_TO_FRONT = 'rear-to-front'
- CHOICES = (
+ CHOICES = [
(FRONT_TO_REAR, _('Front to rear')),
(REAR_TO_FRONT, _('Rear to front')),
- )
+ ]
#
@@ -191,6 +192,7 @@ class DeviceStatusChoices(ChoiceSet):
class DeviceAirflowChoices(ChoiceSet):
+ key = 'Device.airflow'
AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
@@ -203,7 +205,7 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_PASSIVE = 'passive'
AIRFLOW_MIXED = 'mixed'
- CHOICES = (
+ CHOICES = [
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
(AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
@@ -214,7 +216,7 @@ class DeviceAirflowChoices(ChoiceSet):
(AIRFLOW_TOP_TO_BOTTOM, _('Top to bottom')),
(AIRFLOW_PASSIVE, _('Passive')),
(AIRFLOW_MIXED, _('Mixed')),
- )
+ ]
#
@@ -242,6 +244,7 @@ class ModuleStatusChoices(ChoiceSet):
class ModuleAirflowChoices(ChoiceSet):
+ key = 'Module.airflow'
FRONT_TO_REAR = 'front-to-rear'
REAR_TO_FRONT = 'rear-to-front'
@@ -250,14 +253,14 @@ class ModuleAirflowChoices(ChoiceSet):
SIDE_TO_REAR = 'side-to-rear'
PASSIVE = 'passive'
- CHOICES = (
+ CHOICES = [
(FRONT_TO_REAR, _('Front to rear')),
(REAR_TO_FRONT, _('Rear to front')),
(LEFT_TO_RIGHT, _('Left to right')),
(RIGHT_TO_LEFT, _('Right to left')),
(SIDE_TO_REAR, _('Side to rear')),
(PASSIVE, _('Passive')),
- )
+ ]
#
@@ -1635,6 +1638,23 @@ class PowerFeedPhaseChoices(ChoiceSet):
)
+#
+# PowerOutlets
+#
+class PowerOutletStatusChoices(ChoiceSet):
+ key = 'PowerOutlet.status'
+
+ STATUS_ENABLED = 'enabled'
+ STATUS_DISABLED = 'disabled'
+ STATUS_FAULTY = 'faulty'
+
+ CHOICES = [
+ (STATUS_ENABLED, _('Enabled'), 'green'),
+ (STATUS_DISABLED, _('Disabled'), 'red'),
+ (STATUS_FAULTY, _('Faulty'), 'gray'),
+ ]
+
+
#
# VDC
#
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index e01c3f658..a31cf136d 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices
from netbox.filtersets import (
- BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
+ AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
+ OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import *
@@ -58,6 +59,7 @@ __all__ = (
'ModuleBayTemplateFilterSet',
'ModuleFilterSet',
'ModuleTypeFilterSet',
+ 'ModuleTypeProfileFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet',
'PowerConnectionFilterSet',
@@ -81,7 +83,7 @@ __all__ = (
)
-class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
+class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label=_('Parent region (ID)'),
@@ -111,7 +113,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
fields = ('id', 'name', 'slug', 'description')
-class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
+class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label=_('Parent site group (ID)'),
@@ -205,7 +207,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
return queryset.filter(qs_filter).distinct()
-class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -275,13 +277,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
- if not value.strip():
- return queryset
- return queryset.filter(
- Q(name__icontains=value) |
- Q(facility__icontains=value) |
- Q(description__icontains=value)
- )
+ # extended in order to include querying on Location.facility
+ queryset = super().search(queryset, name, value)
+
+ if value.strip():
+ queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
+
+ return queryset
class RackRoleFilterSet(OrganizationalModelFilterSet):
@@ -312,8 +314,8 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = RackType
fields = (
- 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
- 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
+ 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
+ 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
)
def search(self, queryset, name, value):
@@ -425,8 +427,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = (
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
- 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
- 'description',
+ 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
+ 'weight_unit', 'description',
)
def search(self, queryset, name, value):
@@ -673,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
return queryset.exclude(inventoryitemtemplates__isnull=value)
-class ModuleTypeFilterSet(NetBoxModelFilterSet):
+class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
+
+ class Meta:
+ model = ModuleTypeProfile
+ fields = ('id', 'name', 'description')
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
+ profile_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ModuleTypeProfile.objects.all(),
+ label=_('Profile (ID)'),
+ )
+ profile = django_filters.ModelMultipleChoiceFilter(
+ field_name='profile__name',
+ queryset=ModuleTypeProfile.objects.all(),
+ to_field_name='name',
+ label=_('Profile (name)'),
+ )
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
label=_('Manufacturer (ID)'),
@@ -921,6 +949,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
queryset=ConfigTemplate.objects.all(),
label=_('Config template (ID)'),
)
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=DeviceRole.objects.all(),
+ label=_('Parent device role (ID)'),
+ )
+ parent = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent__slug',
+ queryset=DeviceRole.objects.all(),
+ to_field_name='slug',
+ label=_('Parent device role (slug)'),
+ )
+ ancestor_id = TreeNodeMultipleChoiceFilter(
+ queryset=DeviceRole.objects.all(),
+ field_name='parent',
+ lookup_expr='in',
+ label=_('Parent device role (ID)'),
+ )
+ ancestor = TreeNodeMultipleChoiceFilter(
+ queryset=DeviceRole.objects.all(),
+ field_name='parent',
+ lookup_expr='in',
+ to_field_name='slug',
+ label=_('Parent device role (slug)'),
+ )
class Meta:
model = DeviceRole
@@ -989,14 +1040,16 @@ class DeviceFilterSet(
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
- role_id = django_filters.ModelMultipleChoiceFilter(
- field_name='role_id',
+ role_id = TreeNodeMultipleChoiceFilter(
+ field_name='role',
queryset=DeviceRole.objects.all(),
+ lookup_expr='in',
label=_('Role (ID)'),
)
- role = django_filters.ModelMultipleChoiceFilter(
- field_name='role__slug',
+ role = TreeNodeMultipleChoiceFilter(
queryset=DeviceRole.objects.all(),
+ field_name='role',
+ lookup_expr='in',
to_field_name='slug',
label=_('Role (slug)'),
)
@@ -1664,11 +1717,15 @@ class PowerOutletFilterSet(
queryset=PowerPort.objects.all(),
label=_('Power port (ID)'),
)
+ status = django_filters.MultipleChoiceFilter(
+ choices=PowerOutletStatusChoices,
+ null_value=None
+ )
class Meta:
model = PowerOutlet
fields = (
- 'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
+ 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
)
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index e50804df8..098c1a58e 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
-from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import (
+ ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
+)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from virtualization.models import Cluster
@@ -46,6 +48,7 @@ __all__ = (
'ModuleBayBulkEditForm',
'ModuleBayTemplateBulkEditForm',
'ModuleTypeBulkEditForm',
+ 'ModuleTypeProfileBulkEditForm',
'PlatformBulkEditForm',
'PowerFeedBulkEditForm',
'PowerOutletBulkEditForm',
@@ -78,12 +81,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = Region
fieldsets = (
FieldSet('parent', 'description'),
)
- nullable_fields = ('parent', 'description')
+ nullable_fields = ('parent', 'description', 'comments')
class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
@@ -97,12 +101,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = SiteGroup
fieldsets = (
FieldSet('parent', 'description'),
)
- nullable_fields = ('parent', 'description')
+ nullable_fields = ('parent', 'description', 'comments')
class SiteBulkEditForm(NetBoxModelBulkEditForm):
@@ -197,12 +202,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = Location
fieldsets = (
FieldSet('site', 'parent', 'status', 'tenant', 'description'),
)
- nullable_fields = ('parent', 'tenant', 'description')
+ nullable_fields = ('parent', 'tenant', 'description', 'comments')
class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
@@ -257,6 +263,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
+ outer_height = forms.IntegerField(
+ label=_('Outer height'),
+ required=False,
+ min_value=1
+ )
outer_depth = forms.IntegerField(
label=_('Outer depth'),
required=False,
@@ -299,7 +310,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = (
FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet(
- InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
+ InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth',
name=_('Dimensions')
@@ -307,7 +318,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
)
nullable_fields = (
- 'outer_width', 'outer_depth', 'outer_unit', 'weight',
+ 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -401,6 +412,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
+ outer_height = forms.IntegerField(
+ label=_('Outer height'),
+ required=False,
+ min_value=1
+ )
outer_depth = forms.IntegerField(
label=_('Outer depth'),
required=False,
@@ -448,15 +464,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
fieldsets = (
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
- FieldSet(
- 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',
- 'mounting_depth', name=_('Hardware')
- ),
+ FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')),
+ FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
nullable_fields = (
- 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
- 'max_weight', 'weight_unit', 'description', 'comments',
+ 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth',
+ 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -563,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
+class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm):
+ schema = JSONField(
+ label=_('Schema'),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = ModuleTypeProfile
+ fieldsets = (
+ FieldSet('name', 'description', 'schema', name=_('Profile')),
+ )
+ nullable_fields = ('description', 'comments')
+
+
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
+ profile = DynamicModelChoiceField(
+ label=_('Profile'),
+ queryset=ModuleTypeProfile.objects.all(),
+ required=False
+ )
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
@@ -598,17 +636,22 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
model = ModuleType
fieldsets = (
- FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
+ FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
FieldSet(
'airflow',
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
)
- nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
+ nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
+ parent = DynamicModelChoiceField(
+ label=_('Parent'),
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ )
color = ColorField(
label=_('Color'),
required=False
@@ -628,12 +671,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = DeviceRole
fieldsets = (
- FieldSet('color', 'vm_role', 'config_template', 'description'),
+ FieldSet('parent', 'color', 'vm_role', 'config_template', 'description'),
)
- nullable_fields = ('color', 'config_template', 'description')
+ nullable_fields = ('parent', 'color', 'config_template', 'description', 'comments')
class PlatformBulkEditForm(NetBoxModelBulkEditForm):
@@ -1379,7 +1423,10 @@ class PowerPortBulkEditForm(
class PowerOutletBulkEditForm(
ComponentBulkEditForm,
- form_from_model(PowerOutlet, ['label', 'type', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description'])
+ form_from_model(
+ PowerOutlet,
+ ['label', 'type', 'status', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']
+ )
):
mark_connected = forms.NullBooleanField(
label=_('Mark connected'),
@@ -1389,7 +1436,7 @@ class PowerOutletBulkEditForm(
model = PowerOutlet
fieldsets = (
- FieldSet('module', 'type', 'label', 'description', 'mark_connected', 'color'),
+ FieldSet('module', 'type', 'label', 'status', 'description', 'mark_connected', 'color'),
FieldSet('feed_leg', 'power_port', name=_('Power')),
)
nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description')
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index cb36e94bf..3ad4ced91 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -39,6 +39,7 @@ __all__ = (
'ModuleImportForm',
'ModuleBayImportForm',
'ModuleTypeImportForm',
+ 'ModuleTypeProfileImportForm',
'PlatformImportForm',
'PowerFeedImportForm',
'PowerOutletImportForm',
@@ -68,7 +69,7 @@ class RegionImportForm(NetBoxModelImportForm):
class Meta:
model = Region
- fields = ('name', 'slug', 'parent', 'description', 'tags')
+ fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class SiteGroupImportForm(NetBoxModelImportForm):
@@ -82,7 +83,7 @@ class SiteGroupImportForm(NetBoxModelImportForm):
class Meta:
model = SiteGroup
- fields = ('name', 'slug', 'parent', 'description')
+ fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags')
class SiteImportForm(NetBoxModelImportForm):
@@ -160,7 +161,10 @@ class LocationImportForm(NetBoxModelImportForm):
class Meta:
model = Location
- fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags')
+ fields = (
+ 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description',
+ 'tags', 'comments',
+ )
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@@ -219,7 +223,7 @@ class RackTypeImportForm(NetBoxModelImportForm):
model = RackType
fields = (
'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
- 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
+ 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
'weight_unit', 'description', 'comments', 'tags',
)
@@ -304,7 +308,7 @@ class RackImportForm(NetBoxModelImportForm):
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
- 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
+ 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
@@ -424,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
]
+class ModuleTypeProfileImportForm(NetBoxModelImportForm):
+
+ class Meta:
+ model = ModuleTypeProfile
+ fields = [
+ 'name', 'description', 'schema', 'comments', 'tags',
+ ]
+
+
class ModuleTypeImportForm(NetBoxModelImportForm):
+ profile = forms.ModelChoiceField(
+ label=_('Profile'),
+ queryset=ModuleTypeProfile.objects.all(),
+ to_field_name='name',
+ required=False
+ )
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
@@ -457,6 +476,16 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class DeviceRoleImportForm(NetBoxModelImportForm):
+ parent = CSVModelChoiceField(
+ label=_('Parent'),
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Parent Device Role'),
+ error_messages={
+ 'invalid_choice': _('Device role not found.'),
+ }
+ )
config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
@@ -468,7 +497,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class Meta:
model = DeviceRole
- fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
+ fields = (
+ 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags'
+ )
class PlatformImportForm(NetBoxModelImportForm):
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 5d5d25f96..813a578d6 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -39,6 +39,7 @@ __all__ = (
'ModuleFilterForm',
'ModuleBayFilterForm',
'ModuleTypeFilterForm',
+ 'ModuleTypeProfileFilterForm',
'PlatformFilterForm',
'PowerConnectionFilterForm',
'PowerFeedFilterForm',
@@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)
+class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm):
+ model = ModuleTypeProfile
+ fieldsets = (
+ FieldSet('q', 'filter_id', 'tag'),
+ )
+ selector_fields = ('filter_id', 'q')
+
+
class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
+ FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', name=_('Components')
@@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
FieldSet('weight', 'weight_unit', name=_('Weight')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
+ profile_id = DynamicModelMultipleChoiceField(
+ queryset=ModuleTypeProfile.objects.all(),
+ required=False,
+ label=_('Profile')
+ )
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -689,6 +703,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Config template')
)
+ parent_id = DynamicModelMultipleChoiceField(
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ label=_('Parent')
+ )
tag = TagFilterField(model)
@@ -1353,7 +1372,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
+ FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
@@ -1371,6 +1390,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label=_('Color'),
required=False
)
+ status = forms.MultipleChoiceField(
+ label=_('Status'),
+ choices=PowerOutletStatusChoices,
+ required=False
+ )
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 5a3a27d25..d8cff372f 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
+from django.core.validators import EMPTY_VALUES
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField
@@ -18,6 +19,7 @@ from utilities.forms.fields import (
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
+from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm
@@ -48,6 +50,7 @@ __all__ = (
'ModuleBayForm',
'ModuleBayTemplateForm',
'ModuleTypeForm',
+ 'ModuleTypeProfileForm',
'PlatformForm',
'PopulateDeviceBayForm',
'PowerFeedForm',
@@ -78,6 +81,7 @@ class RegionForm(NetBoxModelForm):
required=False
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@@ -86,7 +90,7 @@ class RegionForm(NetBoxModelForm):
class Meta:
model = Region
fields = (
- 'parent', 'name', 'slug', 'description', 'tags',
+ 'parent', 'name', 'slug', 'description', 'tags', 'comments',
)
@@ -97,6 +101,7 @@ class SiteGroupForm(NetBoxModelForm):
required=False
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@@ -105,7 +110,7 @@ class SiteGroupForm(NetBoxModelForm):
class Meta:
model = SiteGroup
fields = (
- 'parent', 'name', 'slug', 'description', 'tags',
+ 'parent', 'name', 'slug', 'description', 'comments', 'tags',
)
@@ -179,6 +184,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
}
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')),
@@ -188,7 +194,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Location
fields = (
- 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags',
+ 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
+ 'facility', 'tags', 'comments',
)
@@ -222,7 +229,7 @@ class RackTypeForm(NetBoxModelForm):
FieldSet('manufacturer', 'model', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')),
FieldSet(
'width', 'u_height',
- InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
+ InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', name=_('Dimensions')
),
@@ -233,8 +240,8 @@ class RackTypeForm(NetBoxModelForm):
model = RackType
fields = [
'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
- 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
- 'description', 'comments', 'tags',
+ 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
+ 'weight_unit', 'description', 'comments', 'tags',
]
@@ -279,8 +286,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
- 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
- 'description', 'comments', 'tags',
+ 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
+ 'weight_unit', 'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -302,7 +309,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
*self.fieldsets,
FieldSet(
'form_factor', 'width', 'starting_unit', 'u_height',
- InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
+ InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit',
+ label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
),
@@ -399,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm):
}
+class ModuleTypeProfileForm(NetBoxModelForm):
+ schema = JSONField(
+ label=_('Schema'),
+ required=False,
+ help_text=_("Enter a valid JSON schema to define supported attributes.")
+ )
+ comments = CommentField()
+
+ fieldsets = (
+ FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')),
+ )
+
+ class Meta:
+ model = ModuleTypeProfile
+ fields = [
+ 'name', 'description', 'schema', 'comments', 'tags',
+ ]
+
+
class ModuleTypeForm(NetBoxModelForm):
+ profile = forms.ModelChoiceField(
+ queryset=ModuleTypeProfile.objects.all(),
+ label=_('Profile'),
+ required=False,
+ widget=HTMXSelect()
+ )
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
comments = CommentField()
- fieldsets = (
- FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
- FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
- )
+ @property
+ def fieldsets(self):
+ return [
+ FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
+ FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
+ FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'))
+ ]
class Meta:
model = ModuleType
fields = [
- 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+ 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
'comments', 'tags',
]
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Track profile-specific attribute fields
+ self.attr_fields = []
+
+ # Retrieve assigned ModuleTypeProfile, if any
+ if not (profile_id := get_field_value(self, 'profile')):
+ return
+ if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()):
+ return
+
+ # Extend form with fields for profile attributes
+ for attr, form_field in self._get_attr_form_fields(profile).items():
+ field_name = f'attr_{attr}'
+ self.attr_fields.append(field_name)
+ self.fields[field_name] = form_field
+ if self.instance.attribute_data:
+ self.fields[field_name].initial = self.instance.attribute_data.get(attr)
+
+ @staticmethod
+ def _get_attr_form_fields(profile):
+ """
+ Return a dictionary mapping of attribute names to form fields, suitable for extending
+ the form per the selected ModuleTypeProfile.
+ """
+ if not profile.schema:
+ return {}
+
+ properties = profile.schema.get('properties', {})
+ required_fields = profile.schema.get('required', [])
+
+ attr_fields = {}
+ for name, options in properties.items():
+ prop = JSONSchemaProperty(**options)
+ attr_fields[name] = prop.to_form_field(name, required=name in required_fields)
+
+ return dict(sorted(attr_fields.items()))
+
+ def _post_clean(self):
+
+ # Compile attribute data from the individual form fields
+ if self.cleaned_data.get('profile'):
+ self.instance.attribute_data = {
+ name[5:]: self.cleaned_data[name] # Remove the attr_ prefix
+ for name in self.attr_fields
+ if self.cleaned_data.get(name) not in EMPTY_VALUES
+ }
+
+ return super()._post_clean()
+
class DeviceRoleForm(NetBoxModelForm):
config_template = DynamicModelChoiceField(
@@ -426,17 +513,24 @@ class DeviceRoleForm(NetBoxModelForm):
required=False
)
slug = SlugField()
+ parent = DynamicModelChoiceField(
+ label=_('Parent'),
+ queryset=DeviceRole.objects.all(),
+ required=False,
+ )
+ comments = CommentField()
fieldsets = (
FieldSet(
- 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role')
+ 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description',
+ 'tags', name=_('Device Role')
),
)
class Meta:
model = DeviceRole
fields = [
- 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
+ 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags',
]
@@ -899,7 +993,7 @@ class ComponentTemplateForm(forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
- queryset=DeviceType.objects.all().all(),
+ queryset=DeviceType.objects.all(),
required=False,
context={
'parent': 'manufacturer',
@@ -914,6 +1008,16 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
}
)
+ fieldsets = (
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'description'
+ ),
+ )
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -930,10 +1034,6 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
class ConsolePortTemplateForm(ModularComponentTemplateForm):
- fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
- )
-
class Meta:
model = ConsolePortTemplate
fields = [
@@ -942,10 +1042,6 @@ class ConsolePortTemplateForm(ModularComponentTemplateForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
- fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'description'),
- )
-
class Meta:
model = ConsoleServerPortTemplate
fields = [
@@ -956,7 +1052,11 @@ class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
),
)
@@ -978,7 +1078,13 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
)
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+ ),
)
class Meta:
@@ -1001,7 +1107,11 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
@@ -1028,8 +1138,11 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
FieldSet(
- 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
- 'description',
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
),
)
@@ -1043,7 +1156,13 @@ class FrontPortTemplateForm(ModularComponentTemplateForm):
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'positions', 'description',
+ ),
)
class Meta:
@@ -1055,7 +1174,13 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'position', 'description',
+ ),
)
class Meta:
@@ -1308,7 +1433,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
fieldsets = (
FieldSet(
- 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected',
+ 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'tags',
),
)
@@ -1316,7 +1441,7 @@ class PowerOutletForm(ModularDeviceComponentForm):
class Meta:
model = PowerOutlet
fields = [
- 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected',
+ 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected',
'description', 'tags',
]
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 1e898a386..bcf91c547 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
-from utilities.forms.rendering import FieldSet
+from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
from . import model_forms
@@ -118,7 +118,13 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = (
- FieldSet('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description'),
+ FieldSet(
+ TabbedGroups(
+ FieldSet('device_type', name=_('Device Type')),
+ FieldSet('module_type', name=_('Module Type')),
+ ),
+ 'name', 'label', 'type', 'color', 'rear_port', 'description',
+ ),
)
class Meta(model_forms.FrontPortTemplateForm.Meta):
diff --git a/netbox/dcim/graphql/enums.py b/netbox/dcim/graphql/enums.py
new file mode 100644
index 000000000..5f888cfb5
--- /dev/null
+++ b/netbox/dcim/graphql/enums.py
@@ -0,0 +1,75 @@
+import strawberry
+
+from dcim.choices import *
+
+__all__ = (
+ 'CableEndEnum',
+ 'CableLengthUnitEnum',
+ 'CableTypeEnum',
+ 'ConsolePortSpeedEnum',
+ 'ConsolePortTypeEnum',
+ 'DeviceAirflowEnum',
+ 'DeviceFaceEnum',
+ 'DeviceStatusEnum',
+ 'InterfaceDuplexEnum',
+ 'InterfaceModeEnum',
+ 'InterfacePoEModeEnum',
+ 'InterfacePoETypeEnum',
+ 'InterfaceTypeEnum',
+ 'InventoryItemStatusEnum',
+ 'LinkStatusEnum',
+ 'LocationStatusEnum',
+ 'ModuleAirflowEnum',
+ 'ModuleStatusEnum',
+ 'PortTypeEnum',
+ 'PowerFeedPhaseEnum',
+ 'PowerFeedStatusEnum',
+ 'PowerFeedSupplyEnum',
+ 'PowerFeedTypeEnum',
+ 'PowerOutletFeedLegEnum',
+ 'PowerOutletTypeEnum',
+ 'PowerPortTypeEnum',
+ 'RackAirflowEnum',
+ 'RackDimensionUnitEnum',
+ 'RackFormFactorEnum',
+ 'RackStatusEnum',
+ 'RackWidthEnum',
+ 'SiteStatusEnum',
+ 'SubdeviceRoleEnum',
+ 'VirtualDeviceContextStatusEnum',
+)
+
+CableEndEnum = strawberry.enum(CableEndChoices.as_enum(prefix='side'))
+CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum(prefix='unit'))
+CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum(prefix='type'))
+ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum(prefix='speed'))
+ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum(prefix='type'))
+DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum(prefix='airflow'))
+DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum(prefix='face'))
+DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum(prefix='status'))
+InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum(prefix='duplex'))
+InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum(prefix='mode'))
+InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum(prefix='mode'))
+InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum())
+InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum(prefix='type'))
+InventoryItemStatusEnum = strawberry.enum(InventoryItemStatusChoices.as_enum(prefix='status'))
+LinkStatusEnum = strawberry.enum(LinkStatusChoices.as_enum(prefix='status'))
+LocationStatusEnum = strawberry.enum(LocationStatusChoices.as_enum(prefix='status'))
+ModuleAirflowEnum = strawberry.enum(ModuleAirflowChoices.as_enum())
+ModuleStatusEnum = strawberry.enum(ModuleStatusChoices.as_enum(prefix='status'))
+PortTypeEnum = strawberry.enum(PortTypeChoices.as_enum(prefix='type'))
+PowerFeedPhaseEnum = strawberry.enum(PowerFeedPhaseChoices.as_enum(prefix='phase'))
+PowerFeedStatusEnum = strawberry.enum(PowerFeedStatusChoices.as_enum(prefix='status'))
+PowerFeedSupplyEnum = strawberry.enum(PowerFeedSupplyChoices.as_enum(prefix='supply'))
+PowerFeedTypeEnum = strawberry.enum(PowerFeedTypeChoices.as_enum(prefix='type'))
+PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum(prefix='feed_leg'))
+PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum(prefix='type'))
+PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum(prefix='type'))
+RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum())
+RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum(prefix='unit'))
+RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum(prefix='type'))
+RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum(prefix='status'))
+RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum(prefix='width'))
+SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum(prefix='status'))
+SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum(prefix='role'))
+VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum(prefix='status'))
diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py
new file mode 100644
index 000000000..25379ad7f
--- /dev/null
+++ b/netbox/dcim/graphql/filter_mixins.py
@@ -0,0 +1,148 @@
+from dataclasses import dataclass
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+from strawberry import ID
+from strawberry_django import FilterLookup
+
+from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin
+from core.graphql.filters import ContentTypeFilter
+from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin
+from .enums import *
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerLookup
+ from extras.graphql.filters import ConfigTemplateFilter
+ from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
+ from .filters import *
+
+__all__ = (
+ 'CabledObjectModelFilterMixin',
+ 'ComponentModelFilterMixin',
+ 'ComponentTemplateFilterMixin',
+ 'InterfaceBaseFilterMixin',
+ 'ModularComponentModelFilterMixin',
+ 'ModularComponentTemplateFilterMixin',
+ 'RackBaseFilterMixin',
+ 'RenderConfigFilterMixin',
+ 'ScopedFilterMixin',
+)
+
+
+@dataclass
+class ScopedFilterMixin(BaseFilterMixin):
+ scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ scope_id: ID | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ComponentModelFilterMixin(NetBoxModelFilterMixin):
+ device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ device_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ label: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ModularComponentModelFilterMixin(ComponentModelFilterMixin):
+ module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ module_id: ID | None = strawberry_django.filter_field()
+ inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class CabledObjectModelFilterMixin(BaseFilterMixin):
+ cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ cable_id: ID | None = strawberry_django.filter_field()
+ cable_end: CableEndEnum | None = strawberry_django.filter_field()
+ mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ComponentTemplateFilterMixin(ChangeLogFilterMixin):
+ device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ device_type_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ label: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin):
+ module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class RenderConfigFilterMixin(BaseFilterMixin):
+ config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ config_template_id: ID | None = strawberry_django.filter_field()
+
+
+@dataclass
+class InterfaceBaseFilterMixin(BaseFilterMixin):
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ mode: InterfaceModeEnum | None = strawberry_django.filter_field()
+ bridge: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ bridge_id: ID | None = strawberry_django.filter_field()
+ untagged_vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ tagged_vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_translation_policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None \
+ = strawberry_django.filter_field()
+ primary_mac_address: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_mac_address_id: ID | None = strawberry_django.filter_field()
+
+
+@dataclass
+class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin):
+ width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ desc_units: FilterLookup[bool] | None = strawberry_django.filter_field()
+ outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ outer_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py
index 94f2c6d38..77e7a53b9 100644
--- a/netbox/dcim/graphql/filters.py
+++ b/netbox/dcim/graphql/filters.py
@@ -1,7 +1,46 @@
-import strawberry_django
+from typing import Annotated, TYPE_CHECKING
-from dcim import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import strawberry
+import strawberry_django
+from strawberry.scalars import ID
+from strawberry_django import FilterLookup
+
+from core.graphql.filter_mixins import ChangeLogFilterMixin
+from dcim import models
+from extras.graphql.filter_mixins import ConfigContextFilterMixin
+from netbox.graphql.filter_mixins import (
+ PrimaryModelFilterMixin,
+ OrganizationalModelFilterMixin,
+ NestedGroupModelFilterMixin,
+ ImageAttachmentFilterMixin,
+ WeightFilterMixin,
+)
+from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin
+from .filter_mixins import (
+ CabledObjectModelFilterMixin,
+ ComponentModelFilterMixin,
+ ComponentTemplateFilterMixin,
+ InterfaceBaseFilterMixin,
+ ModularComponentModelFilterMixin,
+ ModularComponentTemplateFilterMixin,
+ RackBaseFilterMixin,
+ RenderConfigFilterMixin,
+)
+
+if TYPE_CHECKING:
+ from core.graphql.filters import ContentTypeFilter
+ from extras.graphql.filters import ConfigTemplateFilter, ImageAttachmentFilter
+ from ipam.graphql.filters import (
+ ASNFilter, FHRPGroupAssignmentFilter, IPAddressFilter, PrefixFilter, VLANGroupFilter, VRFFilter,
+ )
+ from netbox.graphql.enums import ColorEnum
+ from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter
+ from users.graphql.filters import UserFilter
+ from virtualization.graphql.filters import ClusterFilter
+ from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter
+ from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum
+ from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter
+ from .enums import *
__all__ = (
'CableFilter',
@@ -13,7 +52,6 @@ __all__ = (
'DeviceFilter',
'DeviceBayFilter',
'DeviceBayTemplateFilter',
- 'InventoryItemTemplateFilter',
'DeviceRoleFilter',
'DeviceTypeFilter',
'FrontPortFilter',
@@ -22,6 +60,7 @@ __all__ = (
'InterfaceTemplateFilter',
'InventoryItemFilter',
'InventoryItemRoleFilter',
+ 'InventoryItemTemplateFilter',
'LocationFilter',
'MACAddressFilter',
'ManufacturerFilter',
@@ -29,6 +68,7 @@ __all__ = (
'ModuleBayFilter',
'ModuleBayTemplateFilter',
'ModuleTypeFilter',
+ 'ModuleTypeProfileFilter',
'PlatformFilter',
'PowerFeedFilter',
'PowerOutletFilter',
@@ -51,258 +91,872 @@ __all__ = (
@strawberry_django.filter(models.Cable, lookups=True)
-@autotype_decorator(filtersets.CableFilterSet)
-class CableFilter(BaseFilterMixin):
- pass
+class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
+ type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ label: FilterLookup[str] | None = strawberry_django.filter_field()
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.CableTermination, lookups=True)
-@autotype_decorator(filtersets.CableTerminationFilterSet)
-class CableTerminationFilter(BaseFilterMixin):
- pass
+class CableTerminationFilter(ChangeLogFilterMixin):
+ cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ cable_id: ID | None = strawberry_django.filter_field()
+ cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ termination_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ConsolePort, lookups=True)
-@autotype_decorator(filtersets.ConsolePortFilterSet)
-class ConsolePortFilter(BaseFilterMixin):
- pass
+class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ConsolePortTemplate, lookups=True)
-@autotype_decorator(filtersets.ConsolePortTemplateFilterSet)
-class ConsolePortTemplateFilter(BaseFilterMixin):
- pass
+class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ConsoleServerPort, lookups=True)
-@autotype_decorator(filtersets.ConsoleServerPortFilterSet)
-class ConsoleServerPortFilter(BaseFilterMixin):
- pass
+class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True)
-@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet)
-class ConsoleServerPortTemplateFilter(BaseFilterMixin):
- pass
+class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Device, lookups=True)
-@autotype_decorator(filtersets.DeviceFilterSet)
-class DeviceFilter(BaseFilterMixin):
- pass
+class DeviceFilter(
+ ContactFilterMixin,
+ TenancyFilterMixin,
+ ImageAttachmentFilterMixin,
+ RenderConfigFilterMixin,
+ ConfigContextFilterMixin,
+ PrimaryModelFilterMixin,
+):
+ device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ device_type_id: ID | None = strawberry_django.filter_field()
+ role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ role_id: ID | None = strawberry_django.filter_field()
+ platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ serial: FilterLookup[str] | None = strawberry_django.filter_field()
+ asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ rack_id: ID | None = strawberry_django.filter_field()
+ position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip4_id: ID | None = strawberry_django.filter_field()
+ primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip6_id: ID | None = strawberry_django.filter_field()
+ oob_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ oob_ip_id: ID | None = strawberry_django.filter_field()
+ cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ cluster_id: ID | None = strawberry_django.filter_field()
+ virtual_chassis: Annotated['VirtualChassisFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ virtual_chassis_id: ID | None = strawberry_django.filter_field()
+ vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_port_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ power_port_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ interface_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ front_port_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBay, lookups=True)
-@autotype_decorator(filtersets.DeviceBayFilterSet)
-class DeviceBayFilter(BaseFilterMixin):
- pass
+class DeviceBayFilter(ComponentModelFilterMixin):
+ installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ installed_device_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceBayTemplate, lookups=True)
-@autotype_decorator(filtersets.DeviceBayTemplateFilterSet)
-class DeviceBayTemplateFilter(BaseFilterMixin):
+class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
pass
@strawberry_django.filter(models.InventoryItemTemplate, lookups=True)
-@autotype_decorator(filtersets.InventoryItemTemplateFilterSet)
-class InventoryItemTemplateFilter(BaseFilterMixin):
- pass
+class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
+ parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ component_id: ID | None = strawberry_django.filter_field()
+ role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ role_id: ID | None = strawberry_django.filter_field()
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ part_id: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceRole, lookups=True)
-@autotype_decorator(filtersets.DeviceRoleFilterSet)
-class DeviceRoleFilter(BaseFilterMixin):
- pass
+class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.DeviceType, lookups=True)
-@autotype_decorator(filtersets.DeviceTypeFilterSet)
-class DeviceTypeFilter(BaseFilterMixin):
- pass
+class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ model: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ default_platform_id: ID | None = strawberry_django.filter_field()
+ part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+ u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field()
+ is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field()
+ subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_port_templates: (
+ Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ console_server_port_templates: (
+ Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ power_port_templates: (
+ Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ power_outlet_templates: (
+ Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ interface_templates: (
+ Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ front_port_templates: (
+ Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ rear_port_templates: (
+ Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ device_bay_templates: (
+ Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ module_bay_templates: (
+ Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ inventory_item_templates: (
+ Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
+ inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FrontPort, lookups=True)
-@autotype_decorator(filtersets.FrontPortFilterSet)
-class FrontPortFilter(BaseFilterMixin):
- pass
+class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rear_port_id: ID | None = strawberry_django.filter_field()
+ rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.FrontPortTemplate, lookups=True)
-@autotype_decorator(filtersets.FrontPortTemplateFilterSet)
-class FrontPortTemplateFilter(BaseFilterMixin):
- pass
+class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rear_port_id: ID | None = strawberry_django.filter_field()
+ rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.MACAddress, lookups=True)
-@autotype_decorator(filtersets.MACAddressFilterSet)
-class MACAddressFilter(BaseFilterMixin):
- pass
+class MACAddressFilter(PrimaryModelFilterMixin):
+ mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Interface, lookups=True)
-@autotype_decorator(filtersets.InterfaceFilterSet)
-class InterfaceFilter(BaseFilterMixin):
- pass
+class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
+ vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ lag_id: ID | None = strawberry_django.filter_field()
+ type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
+ speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ wwn: FilterLookup[str] | None = strawberry_django.filter_field()
+ parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ parent_id: ID | None = strawberry_django.filter_field()
+ rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ wireless_link_id: ID | None = strawberry_django.filter_field()
+ wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ fhrp_group_assignments: Annotated['FHRPGroupAssignmentFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ tunnel_terminations: Annotated['TunnelTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.InterfaceTemplate, lookups=True)
-@autotype_decorator(filtersets.InterfaceTemplateFilterSet)
-class InterfaceTemplateFilter(BaseFilterMixin):
- pass
+class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
+ bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ bridge_id: ID | None = strawberry_django.filter_field()
+ poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.InventoryItem, lookups=True)
-@autotype_decorator(filtersets.InventoryItemFilterSet)
-class InventoryItemFilter(BaseFilterMixin):
- pass
+class InventoryItemFilter(ComponentModelFilterMixin):
+ parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ parent_id: ID | None = strawberry_django.filter_field()
+ component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ component_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ role_id: ID | None = strawberry_django.filter_field()
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ part_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ serial: FilterLookup[str] | None = strawberry_django.filter_field()
+ asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+ discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.InventoryItemRole, lookups=True)
-@autotype_decorator(filtersets.InventoryItemRoleFilterSet)
-class InventoryItemRoleFilter(BaseFilterMixin):
- pass
+class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Location, lookups=True)
-@autotype_decorator(filtersets.LocationFilterSet)
-class LocationFilter(BaseFilterMixin):
- pass
+class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ facility: FilterLookup[str] | None = strawberry_django.filter_field()
+ prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Manufacturer, lookups=True)
-@autotype_decorator(filtersets.ManufacturerFilterSet)
-class ManufacturerFilter(BaseFilterMixin):
+class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
pass
@strawberry_django.filter(models.Module, lookups=True)
-@autotype_decorator(filtersets.ModuleFilterSet)
-class ModuleFilter(BaseFilterMixin):
- pass
+class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
+ device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ device_id: ID | None = strawberry_django.filter_field()
+ module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ module_bay_id: ID | None = strawberry_django.filter_field()
+ module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ module_type_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ serial: FilterLookup[str] | None = strawberry_django.filter_field()
+ asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+ console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ModuleBay, lookups=True)
-@autotype_decorator(filtersets.ModuleBayFilterSet)
-class ModuleBayFilter(BaseFilterMixin):
- pass
+class ModuleBayFilter(ModularComponentModelFilterMixin):
+ parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ parent_id: ID | None = strawberry_django.filter_field()
+ position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleBayTemplate, lookups=True)
-@autotype_decorator(filtersets.ModuleBayTemplateFilterSet)
-class ModuleBayTemplateFilter(BaseFilterMixin):
- pass
+class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
+ position: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@strawberry_django.filter(models.ModuleTypeProfile, lookups=True)
+class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleType, lookups=True)
-@autotype_decorator(filtersets.ModuleTypeFilterSet)
-class ModuleTypeFilter(BaseFilterMixin):
- pass
+class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ profile: Annotated['ModuleTypeProfileFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ profile_id: ID | None = strawberry_django.filter_field()
+ model: FilterLookup[str] | None = strawberry_django.filter_field()
+ part_number: FilterLookup[str] | None = strawberry_django.filter_field()
+ airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ console_port_templates: (
+ Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ console_server_port_templates: (
+ Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ power_port_templates: (
+ Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ power_outlet_templates: (
+ Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ interface_templates: (
+ Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ front_port_templates: (
+ Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ rear_port_templates: (
+ Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ device_bay_templates: (
+ Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ module_bay_templates: (
+ Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ inventory_item_templates: (
+ Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
@strawberry_django.filter(models.Platform, lookups=True)
-@autotype_decorator(filtersets.PlatformFilterSet)
-class PlatformFilter(BaseFilterMixin):
- pass
+class PlatformFilter(OrganizationalModelFilterMixin):
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ config_template_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerFeed, lookups=True)
-@autotype_decorator(filtersets.PowerFeedFilterSet)
-class PowerFeedFilter(BaseFilterMixin):
- pass
+class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_panel_id: ID | None = strawberry_django.filter_field()
+ rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ rack_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.PowerOutlet, lookups=True)
-@autotype_decorator(filtersets.PowerOutletFilterSet)
-class PowerOutletFilter(BaseFilterMixin):
- pass
+class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_port_id: ID | None = strawberry_django.filter_field()
+ feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerOutletTemplate, lookups=True)
-@autotype_decorator(filtersets.PowerOutletTemplateFilterSet)
-class PowerOutletTemplateFilter(BaseFilterMixin):
- pass
+class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
+ type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ power_port_id: ID | None = strawberry_django.filter_field()
+ feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.PowerPanel, lookups=True)
-@autotype_decorator(filtersets.PowerPanelFilterSet)
-class PowerPanelFilter(BaseFilterMixin):
- pass
+class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.PowerPort, lookups=True)
-@autotype_decorator(filtersets.PowerPortFilterSet)
-class PowerPortFilter(BaseFilterMixin):
- pass
+class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.PowerPortTemplate, lookups=True)
-@autotype_decorator(filtersets.PowerPortTemplateFilterSet)
-class PowerPortTemplateFilter(BaseFilterMixin):
- pass
+class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.RackType, lookups=True)
-@autotype_decorator(filtersets.RackTypeFilterSet)
-class RackTypeFilter(BaseFilterMixin):
- pass
+class RackTypeFilter(RackBaseFilterMixin):
+ form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ manufacturer_id: ID | None = strawberry_django.filter_field()
+ model: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Rack, lookups=True)
-@autotype_decorator(filtersets.RackFilterSet)
-class RackFilter(BaseFilterMixin):
- pass
+class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
+ form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ rack_type_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ facility_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ role_id: ID | None = strawberry_django.filter_field()
+ serial: FilterLookup[str] | None = strawberry_django.filter_field()
+ asset_tag: FilterLookup[str] | None = strawberry_django.filter_field()
+ airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.RackReservation, lookups=True)
-@autotype_decorator(filtersets.RackReservationFilterSet)
-class RackReservationFilter(BaseFilterMixin):
- pass
+class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ rack_id: ID | None = strawberry_django.filter_field()
+ units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+ user_id: ID | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RackRole, lookups=True)
-@autotype_decorator(filtersets.RackRoleFilterSet)
-class RackRoleFilter(BaseFilterMixin):
- pass
+class RackRoleFilter(OrganizationalModelFilterMixin):
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RearPort, lookups=True)
-@autotype_decorator(filtersets.RearPortFilterSet)
-class RearPortFilter(BaseFilterMixin):
- pass
+class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+ type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.RearPortTemplate, lookups=True)
-@autotype_decorator(filtersets.RearPortTemplateFilterSet)
-class RearPortTemplateFilter(BaseFilterMixin):
- pass
+class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
+ type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Region, lookups=True)
-@autotype_decorator(filtersets.RegionFilterSet)
-class RegionFilter(BaseFilterMixin):
- pass
+class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
+ prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Site, lookups=True)
-@autotype_decorator(filtersets.SiteFilterSet)
-class SiteFilter(BaseFilterMixin):
- pass
+class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field()
+ region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ facility: FilterLookup[str] | None = strawberry_django.filter_field()
+ asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ time_zone: FilterLookup[str] | None = strawberry_django.filter_field()
+ physical_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ shipping_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.SiteGroup, lookups=True)
-@autotype_decorator(filtersets.SiteGroupFilterSet)
-class SiteGroupFilter(BaseFilterMixin):
- pass
+class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
+ prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VirtualChassis, lookups=True)
-@autotype_decorator(filtersets.VirtualChassisFilterSet)
-class VirtualChassisFilter(BaseFilterMixin):
- pass
+class VirtualChassisFilter(PrimaryModelFilterMixin):
+ master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ master_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ domain: FilterLookup[str] | None = strawberry_django.filter_field()
+ members: (
+ Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ member_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VirtualDeviceContext, lookups=True)
-@autotype_decorator(filtersets.VirtualDeviceContextFilterSet)
-class VirtualDeviceContextFilter(BaseFilterMixin):
- pass
+class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ device_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip4_id: ID | None = strawberry_django.filter_field()
+ primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ primary_ip6_id: ID | None = strawberry_django.filter_field()
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
+ interfaces: (
+ Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py
index 011a2b58b..1b0661bc2 100644
--- a/netbox/dcim/graphql/schema.py
+++ b/netbox/dcim/graphql/schema.py
@@ -77,6 +77,9 @@ class DCIMQuery:
module_bay_template: ModuleBayTemplateType = strawberry_django.field()
module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field()
+ module_type_profile: ModuleTypeProfileType = strawberry_django.field()
+ module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field()
+
module_type: ModuleTypeType = strawberry_django.field()
module_type_list: List[ModuleTypeType] = strawberry_django.field()
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index b644cc50c..7f801c01b 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List, Union
+from typing import Annotated, List, TYPE_CHECKING, Union
import strawberry
import strawberry_django
@@ -6,7 +6,11 @@ import strawberry_django
from core.graphql.mixins import ChangelogMixin
from dcim import models
from extras.graphql.mixins import (
- ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
+ ConfigContextMixin,
+ ContactsMixin,
+ CustomFieldsMixin,
+ ImageAttachmentsMixin,
+ TagsMixin,
)
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
@@ -14,6 +18,23 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa
from .filters import *
from .mixins import CabledObjectMixin, PathEndpointMixin
+if TYPE_CHECKING:
+ from circuits.graphql.types import CircuitTerminationType
+ from extras.graphql.types import ConfigTemplateType
+ from ipam.graphql.types import (
+ ASNType,
+ IPAddressType,
+ PrefixType,
+ ServiceType,
+ VLANTranslationPolicyType,
+ VLANType,
+ VRFType,
+ )
+ from tenancy.graphql.types import TenantType
+ from users.graphql.types import UserType
+ from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
+ from wireless.graphql.types import WirelessLANType, WirelessLinkType
+
__all__ = (
'CableType',
'ComponentType',
@@ -40,6 +61,7 @@ __all__ = (
'ModuleType',
'ModuleBayType',
'ModuleBayTemplateType',
+ 'ModuleTypeProfileType',
'ModuleTypeType',
'PlatformType',
'PowerFeedType',
@@ -111,8 +133,9 @@ class ModularComponentTemplateType(ComponentTemplateType):
@strawberry_django.type(
models.CableTermination,
- exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'),
- filters=CableTerminationFilter
+ exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
+ filters=CableTerminationFilter,
+ pagination=True
)
class CableTerminationType(NetBoxObjectType):
cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None
@@ -132,7 +155,8 @@ class CableTerminationType(NetBoxObjectType):
@strawberry_django.type(
models.Cable,
fields='__all__',
- filters=CableFilter
+ filters=CableFilter,
+ pagination=True
)
class CableType(NetBoxObjectType):
color: str
@@ -167,8 +191,9 @@ class CableType(NetBoxObjectType):
@strawberry_django.type(
models.ConsolePort,
- exclude=('_path',),
- filters=ConsolePortFilter
+ exclude=['_path'],
+ filters=ConsolePortFilter,
+ pagination=True
)
class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
pass
@@ -177,7 +202,8 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
@strawberry_django.type(
models.ConsolePortTemplate,
fields='__all__',
- filters=ConsolePortTemplateFilter
+ filters=ConsolePortTemplateFilter,
+ pagination=True
)
class ConsolePortTemplateType(ModularComponentTemplateType):
pass
@@ -185,8 +211,9 @@ class ConsolePortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.ConsoleServerPort,
- exclude=('_path',),
- filters=ConsoleServerPortFilter
+ exclude=['_path'],
+ filters=ConsoleServerPortFilter,
+ pagination=True
)
class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
pass
@@ -195,7 +222,8 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin
@strawberry_django.type(
models.ConsoleServerPortTemplate,
fields='__all__',
- filters=ConsoleServerPortTemplateFilter
+ filters=ConsoleServerPortTemplateFilter,
+ pagination=True
)
class ConsoleServerPortTemplateType(ModularComponentTemplateType):
pass
@@ -204,7 +232,8 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.Device,
fields='__all__',
- filters=DeviceFilter
+ filters=DeviceFilter,
+ pagination=True
)
class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
console_port_count: BigInt
@@ -259,7 +288,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo
@strawberry_django.type(
models.DeviceBay,
fields='__all__',
- filters=DeviceBayFilter
+ filters=DeviceBayFilter,
+ pagination=True
)
class DeviceBayType(ComponentType):
installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
@@ -268,7 +298,8 @@ class DeviceBayType(ComponentType):
@strawberry_django.type(
models.DeviceBayTemplate,
fields='__all__',
- filters=DeviceBayTemplateFilter
+ filters=DeviceBayTemplateFilter,
+ pagination=True
)
class DeviceBayTemplateType(ComponentTemplateType):
pass
@@ -276,8 +307,9 @@ class DeviceBayTemplateType(ComponentTemplateType):
@strawberry_django.type(
models.InventoryItemTemplate,
- exclude=('component_type', 'component_id', 'parent'),
- filters=InventoryItemTemplateFilter
+ exclude=['component_type', 'component_id', 'parent'],
+ filters=InventoryItemTemplateFilter,
+ pagination=True
)
class InventoryItemTemplateType(ComponentTemplateType):
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
@@ -303,9 +335,12 @@ class InventoryItemTemplateType(ComponentTemplateType):
@strawberry_django.type(
models.DeviceRole,
fields='__all__',
- filters=DeviceRoleFilter
+ filters=DeviceRoleFilter,
+ pagination=True
)
class DeviceRoleType(OrganizationalObjectType):
+ parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None
+ children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]]
color: str
config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None
@@ -316,7 +351,8 @@ class DeviceRoleType(OrganizationalObjectType):
@strawberry_django.type(
models.DeviceType,
fields='__all__',
- filters=DeviceTypeFilter
+ filters=DeviceTypeFilter,
+ pagination=True
)
class DeviceTypeType(NetBoxObjectType):
console_port_template_count: BigInt
@@ -350,7 +386,8 @@ class DeviceTypeType(NetBoxObjectType):
@strawberry_django.type(
models.FrontPort,
fields='__all__',
- filters=FrontPortFilter
+ filters=FrontPortFilter,
+ pagination=True
)
class FrontPortType(ModularComponentType, CabledObjectMixin):
color: str
@@ -360,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
@strawberry_django.type(
models.FrontPortTemplate,
fields='__all__',
- filters=FrontPortTemplateFilter
+ filters=FrontPortTemplateFilter,
+ pagination=True
)
class FrontPortTemplateType(ModularComponentTemplateType):
color: str
@@ -369,8 +407,9 @@ class FrontPortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.MACAddress,
- exclude=('assigned_object_type', 'assigned_object_id'),
- filters=MACAddressFilter
+ exclude=['assigned_object_type', 'assigned_object_id'],
+ filters=MACAddressFilter,
+ pagination=True
)
class MACAddressType(NetBoxObjectType):
mac_address: str
@@ -385,8 +424,9 @@ class MACAddressType(NetBoxObjectType):
@strawberry_django.type(
models.Interface,
- exclude=('_path',),
- filters=InterfaceFilter
+ exclude=['_path'],
+ filters=InterfaceFilter,
+ pagination=True
)
class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin):
_name: str
@@ -413,7 +453,8 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
@strawberry_django.type(
models.InterfaceTemplate,
fields='__all__',
- filters=InterfaceTemplateFilter
+ filters=InterfaceTemplateFilter,
+ pagination=True
)
class InterfaceTemplateType(ModularComponentTemplateType):
_name: str
@@ -424,8 +465,9 @@ class InterfaceTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.InventoryItem,
- exclude=('component_type', 'component_id', 'parent'),
- filters=InventoryItemFilter
+ exclude=['component_type', 'component_id', 'parent'],
+ filters=InventoryItemFilter,
+ pagination=True
)
class InventoryItemType(ComponentType):
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
@@ -451,7 +493,8 @@ class InventoryItemType(ComponentType):
@strawberry_django.type(
models.InventoryItemRole,
fields='__all__',
- filters=InventoryItemRoleFilter
+ filters=InventoryItemRoleFilter,
+ pagination=True
)
class InventoryItemRoleType(OrganizationalObjectType):
color: str
@@ -463,8 +506,9 @@ class InventoryItemRoleType(OrganizationalObjectType):
@strawberry_django.type(
models.Location,
# fields='__all__',
- exclude=('parent',), # bug - temp
- filters=LocationFilter
+ exclude=['parent'], # bug - temp
+ filters=LocationFilter,
+ pagination=True
)
class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType):
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
@@ -491,7 +535,8 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
@strawberry_django.type(
models.Manufacturer,
fields='__all__',
- filters=ManufacturerFilter
+ filters=ManufacturerFilter,
+ pagination=True
)
class ManufacturerType(OrganizationalObjectType, ContactsMixin):
@@ -505,7 +550,8 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin):
@strawberry_django.type(
models.Module,
fields='__all__',
- filters=ModuleFilter
+ filters=ModuleFilter,
+ pagination=True
)
class ModuleType(NetBoxObjectType):
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]
@@ -524,8 +570,9 @@ class ModuleType(NetBoxObjectType):
@strawberry_django.type(
models.ModuleBay,
# fields='__all__',
- exclude=('parent',),
- filters=ModuleBayFilter
+ exclude=['parent'],
+ filters=ModuleBayFilter,
+ pagination=True
)
class ModuleBayType(ModularComponentType):
@@ -540,18 +587,31 @@ class ModuleBayType(ModularComponentType):
@strawberry_django.type(
models.ModuleBayTemplate,
fields='__all__',
- filters=ModuleBayTemplateFilter
+ filters=ModuleBayTemplateFilter,
+ pagination=True
)
class ModuleBayTemplateType(ModularComponentTemplateType):
pass
+@strawberry_django.type(
+ models.ModuleTypeProfile,
+ fields='__all__',
+ filters=ModuleTypeProfileFilter,
+ pagination=True
+)
+class ModuleTypeProfileType(NetBoxObjectType):
+ module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
+
+
@strawberry_django.type(
models.ModuleType,
fields='__all__',
- filters=ModuleTypeFilter
+ filters=ModuleTypeFilter,
+ pagination=True
)
class ModuleTypeType(NetBoxObjectType):
+ profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@@ -567,7 +627,8 @@ class ModuleTypeType(NetBoxObjectType):
@strawberry_django.type(
models.Platform,
fields='__all__',
- filters=PlatformFilter
+ filters=PlatformFilter,
+ pagination=True
)
class PlatformType(OrganizationalObjectType):
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
@@ -579,8 +640,9 @@ class PlatformType(OrganizationalObjectType):
@strawberry_django.type(
models.PowerFeed,
- exclude=('_path',),
- filters=PowerFeedFilter
+ exclude=['_path'],
+ filters=PowerFeedFilter,
+ pagination=True
)
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')]
@@ -590,8 +652,9 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
@strawberry_django.type(
models.PowerOutlet,
- exclude=('_path',),
- filters=PowerOutletFilter
+ exclude=['_path'],
+ filters=PowerOutletFilter,
+ pagination=True
)
class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None
@@ -601,7 +664,8 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
@strawberry_django.type(
models.PowerOutletTemplate,
fields='__all__',
- filters=PowerOutletTemplateFilter
+ filters=PowerOutletTemplateFilter,
+ pagination=True
)
class PowerOutletTemplateType(ModularComponentTemplateType):
power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None
@@ -610,7 +674,8 @@ class PowerOutletTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.PowerPanel,
fields='__all__',
- filters=PowerPanelFilter
+ filters=PowerPanelFilter,
+ pagination=True
)
class PowerPanelType(NetBoxObjectType, ContactsMixin):
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
@@ -621,8 +686,9 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
@strawberry_django.type(
models.PowerPort,
- exclude=('_path',),
- filters=PowerPortFilter
+ exclude=['_path'],
+ filters=PowerPortFilter,
+ pagination=True
)
class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
@@ -632,7 +698,8 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
@strawberry_django.type(
models.PowerPortTemplate,
fields='__all__',
- filters=PowerPortTemplateFilter
+ filters=PowerPortTemplateFilter,
+ pagination=True
)
class PowerPortTemplateType(ModularComponentTemplateType):
poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@@ -641,7 +708,8 @@ class PowerPortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.RackType,
fields='__all__',
- filters=RackTypeFilter
+ filters=RackTypeFilter,
+ pagination=True
)
class RackTypeType(NetBoxObjectType):
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -650,7 +718,8 @@ class RackTypeType(NetBoxObjectType):
@strawberry_django.type(
models.Rack,
fields='__all__',
- filters=RackFilter
+ filters=RackFilter,
+ pagination=True
)
class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]
@@ -668,7 +737,8 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
@strawberry_django.type(
models.RackReservation,
fields='__all__',
- filters=RackReservationFilter
+ filters=RackReservationFilter,
+ pagination=True
)
class RackReservationType(NetBoxObjectType):
units: List[int]
@@ -680,7 +750,8 @@ class RackReservationType(NetBoxObjectType):
@strawberry_django.type(
models.RackRole,
fields='__all__',
- filters=RackRoleFilter
+ filters=RackRoleFilter,
+ pagination=True
)
class RackRoleType(OrganizationalObjectType):
color: str
@@ -691,7 +762,8 @@ class RackRoleType(OrganizationalObjectType):
@strawberry_django.type(
models.RearPort,
fields='__all__',
- filters=RearPortFilter
+ filters=RearPortFilter,
+ pagination=True
)
class RearPortType(ModularComponentType, CabledObjectMixin):
color: str
@@ -702,7 +774,8 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
@strawberry_django.type(
models.RearPortTemplate,
fields='__all__',
- filters=RearPortTemplateFilter
+ filters=RearPortTemplateFilter,
+ pagination=True
)
class RearPortTemplateType(ModularComponentTemplateType):
color: str
@@ -712,9 +785,9 @@ class RearPortTemplateType(ModularComponentTemplateType):
@strawberry_django.type(
models.Region,
- exclude=('parent',),
- # fields='__all__',
- filters=RegionFilter
+ exclude=['parent'],
+ filters=RegionFilter,
+ pagination=True
)
class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@@ -739,7 +812,8 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.type(
models.Site,
fields='__all__',
- filters=SiteFilter
+ filters=SiteFilter,
+ pagination=True
)
class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
time_zone: str | None
@@ -772,9 +846,9 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
@strawberry_django.type(
models.SiteGroup,
- # fields='__all__',
- exclude=('parent',), # bug - temp
- filters=SiteGroupFilter
+ exclude=['parent'], # bug - temp
+ filters=SiteGroupFilter,
+ pagination=True
)
class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@@ -799,7 +873,8 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
@strawberry_django.type(
models.VirtualChassis,
fields='__all__',
- filters=VirtualChassisFilter
+ filters=VirtualChassisFilter,
+ pagination=True
)
class VirtualChassisType(NetBoxObjectType):
member_count: BigInt
@@ -811,7 +886,8 @@ class VirtualChassisType(NetBoxObjectType):
@strawberry_django.type(
models.VirtualDeviceContext,
fields='__all__',
- filters=VirtualDeviceContextFilter
+ filters=VirtualDeviceContextFilter,
+ pagination=True
)
class VirtualDeviceContextType(NetBoxObjectType):
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py
index 2e830560f..ae1966e58 100644
--- a/netbox/dcim/migrations/0002_squashed.py
+++ b/netbox/dcim/migrations/0002_squashed.py
@@ -7,11 +7,11 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0001_initial'),
+ ('dcim', '0001_squashed'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
- ('extras', '0001_initial'),
- ('tenancy', '0001_initial'),
+ ('extras', '0001_squashed'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py
index 0248d9ba1..490ab8e8b 100644
--- a/netbox/dcim/migrations/0003_squashed_0130.py
+++ b/netbox/dcim/migrations/0003_squashed_0130.py
@@ -5,12 +5,12 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0002_auto_20160622_1821'),
- ('virtualization', '0001_virtualization'),
+ ('dcim', '0002_squashed'),
+ ('virtualization', '0001_squashed_0022'),
('contenttypes', '0002_remove_content_type_name'),
- ('ipam', '0001_initial'),
- ('tenancy', '0001_initial'),
- ('extras', '0002_custom_fields'),
+ ('ipam', '0001_squashed'),
+ ('tenancy', '0001_squashed_0012'),
+ ('extras', '0002_squashed_0059'),
]
replaces = [
@@ -505,28 +505,6 @@ class Migration(migrations.Migration):
model_name='cable',
name='termination_a_type',
field=models.ForeignKey(
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))),
- models.Q(
- ('app_label', 'dcim'),
- (
- 'model__in',
- (
- 'consoleport',
- 'consoleserverport',
- 'frontport',
- 'interface',
- 'powerfeed',
- 'poweroutlet',
- 'powerport',
- 'rearport',
- ),
- ),
- ),
- _connector='OR',
- )
- ),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
@@ -536,28 +514,6 @@ class Migration(migrations.Migration):
model_name='cable',
name='termination_b_type',
field=models.ForeignKey(
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))),
- models.Q(
- ('app_label', 'dcim'),
- (
- 'model__in',
- (
- 'consoleport',
- 'consoleserverport',
- 'frontport',
- 'interface',
- 'powerfeed',
- 'poweroutlet',
- 'powerport',
- 'rearport',
- ),
- ),
- ),
- _connector='OR',
- )
- ),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py
index 3866e8cc8..1c1f2ff38 100644
--- a/netbox/dcim/migrations/0131_squashed_0159.py
+++ b/netbox/dcim/migrations/0131_squashed_0159.py
@@ -43,12 +43,12 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('tenancy', '0012_standardize_models'),
+ ('tenancy', '0001_squashed_0012'),
('extras', '0002_squashed_0059'),
- ('dcim', '0130_sitegroup'),
+ ('dcim', '0003_squashed_0130'),
('contenttypes', '0002_remove_content_type_name'),
- ('ipam', '0053_asn_model'),
- ('wireless', '0001_wireless'),
+ ('ipam', '0047_squashed_0053'),
+ ('wireless', '0001_squashed_0008'),
]
operations = [
@@ -866,21 +866,6 @@ class Migration(migrations.Migration):
name='component_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- ('app_label', 'dcim'),
- (
- 'model__in',
- (
- 'consoleport',
- 'consoleserverport',
- 'frontport',
- 'interface',
- 'poweroutlet',
- 'powerport',
- 'rearport',
- ),
- ),
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
@@ -1238,21 +1223,6 @@ class Migration(migrations.Migration):
'component_type',
models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- ('app_label', 'dcim'),
- (
- 'model__in',
- (
- 'consoleporttemplate',
- 'consoleserverporttemplate',
- 'frontporttemplate',
- 'interfacetemplate',
- 'poweroutlettemplate',
- 'powerporttemplate',
- 'rearporttemplate',
- ),
- ),
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
@@ -1478,28 +1448,6 @@ class Migration(migrations.Migration):
(
'termination_type',
models.ForeignKey(
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))),
- models.Q(
- ('app_label', 'dcim'),
- (
- 'model__in',
- (
- 'consoleport',
- 'consoleserverport',
- 'frontport',
- 'interface',
- 'powerfeed',
- 'poweroutlet',
- 'powerport',
- 'rearport',
- ),
- ),
- ),
- _connector='OR',
- )
- ),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
diff --git a/netbox/dcim/migrations/0160_squashed_0166.py b/netbox/dcim/migrations/0160_squashed_0166.py
index 0deb58bab..5cff94f4a 100644
--- a/netbox/dcim/migrations/0160_squashed_0166.py
+++ b/netbox/dcim/migrations/0160_squashed_0166.py
@@ -18,9 +18,9 @@ class Migration(migrations.Migration):
dependencies = [
('ipam', '0047_squashed_0053'),
- ('tenancy', '0009_standardize_description_comments'),
- ('circuits', '0037_new_cabling_models'),
- ('dcim', '0159_populate_cable_paths'),
+ ('tenancy', '0001_squashed_0012'),
+ ('circuits', '0003_squashed_0037'),
+ ('dcim', '0131_squashed_0159'),
]
operations = [
diff --git a/netbox/dcim/migrations/0167_squashed_0182.py b/netbox/dcim/migrations/0167_squashed_0182.py
index d0ad5379f..ba077ff4e 100644
--- a/netbox/dcim/migrations/0167_squashed_0182.py
+++ b/netbox/dcim/migrations/0167_squashed_0182.py
@@ -27,10 +27,10 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('extras', '0086_configtemplate'),
- ('tenancy', '0010_tenant_relax_uniqueness'),
+ ('extras', '0060_squashed_0086'),
+ ('tenancy', '0002_squashed_0011'),
('ipam', '0047_squashed_0053'),
- ('dcim', '0166_virtualdevicecontext'),
+ ('dcim', '0160_squashed_0166'),
]
operations = [
diff --git a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py
index f9f2c20b4..2e3edb08a 100644
--- a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py
+++ b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0182_zero_length_cable_fix'),
+ ('dcim', '0167_squashed_0182'),
]
operations = [
diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py
index ae18d5f63..c668858b4 100644
--- a/netbox/dcim/migrations/0199_macaddress.py
+++ b/netbox/dcim/migrations/0199_macaddress.py
@@ -31,13 +31,6 @@ class Migration(migrations.Migration):
'assigned_object_type',
models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/dcim/migrations/0201_add_power_outlet_status.py b/netbox/dcim/migrations/0201_add_power_outlet_status.py
new file mode 100644
index 000000000..21fd32186
--- /dev/null
+++ b/netbox/dcim/migrations/0201_add_power_outlet_status.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0200_populate_mac_addresses'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='poweroutlet',
+ name='status',
+ field=models.CharField(default='enabled', max_length=50),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py
new file mode 100644
index 000000000..ffdc5ba8a
--- /dev/null
+++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0201_add_power_outlet_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='location',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='region',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='sitegroup',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0203_add_rack_outer_height.py b/netbox/dcim/migrations/0203_add_rack_outer_height.py
new file mode 100644
index 000000000..2d2fef265
--- /dev/null
+++ b/netbox/dcim/migrations/0203_add_rack_outer_height.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2b1 on 2025-03-18 15:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0202_location_comments_region_comments_sitegroup_comments'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='rack',
+ name='outer_height',
+ field=models.PositiveSmallIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='racktype',
+ name='outer_height',
+ field=models.PositiveSmallIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0203_device_role_nested.py b/netbox/dcim/migrations/0203_device_role_nested.py
new file mode 100644
index 000000000..c9dd791b3
--- /dev/null
+++ b/netbox/dcim/migrations/0203_device_role_nested.py
@@ -0,0 +1,65 @@
+# Generated by Django 5.1.7 on 2025-03-25 18:06
+
+import django.db.models.manager
+import mptt.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0203_add_rack_outer_height'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='devicerole',
+ name='level',
+ field=models.PositiveIntegerField(default=0, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='lft',
+ field=models.PositiveIntegerField(default=0, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='rght',
+ field=models.PositiveIntegerField(default=0, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='tree_id',
+ field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='parent',
+ field=mptt.fields.TreeForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='children',
+ to='dcim.devicerole',
+ ),
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AlterField(
+ model_name='devicerole',
+ name='name',
+ field=models.CharField(max_length=100),
+ ),
+ migrations.AlterField(
+ model_name='devicerole',
+ name='slug',
+ field=models.SlugField(max_length=100),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0204_device_role_rebuild.py b/netbox/dcim/migrations/0204_device_role_rebuild.py
new file mode 100644
index 000000000..69837c522
--- /dev/null
+++ b/netbox/dcim/migrations/0204_device_role_rebuild.py
@@ -0,0 +1,22 @@
+from django.db import migrations
+import mptt
+import mptt.managers
+
+
+def rebuild_mptt(apps, schema_editor):
+ manager = mptt.managers.TreeManager()
+ DeviceRole = apps.get_model('dcim', 'DeviceRole')
+ manager.model = DeviceRole
+ mptt.register(DeviceRole)
+ manager.contribute_to_class(DeviceRole, 'objects')
+ manager.rebuild()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0203_device_role_nested'),
+ ]
+
+ operations = [
+ migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py
new file mode 100644
index 000000000..25ab3415b
--- /dev/null
+++ b/netbox/dcim/migrations/0205_moduletypeprofile.py
@@ -0,0 +1,57 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import utilities.json
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('dcim', '0204_device_role_rebuild'),
+ ('extras', '0126_exporttemplate_file_name'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ModuleTypeProfile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ (
+ 'custom_field_data',
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('schema', models.JSONField(blank=True, null=True)),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'module type profile',
+ 'verbose_name_plural': 'module type profiles',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.AddField(
+ model_name='moduletype',
+ name='attribute_data',
+ field=models.JSONField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='moduletype',
+ name='profile',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='module_types',
+ to='dcim.moduletypeprofile',
+ ),
+ ),
+ migrations.AlterModelOptions(
+ name='moduletype',
+ options={'ordering': ('profile', 'manufacturer', 'model')},
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py
new file mode 100644
index 000000000..e3ca7d27a
--- /dev/null
+++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py
@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+
+from django.db import migrations
+
+DATA_FILES_PATH = Path(__file__).parent / 'initial_data' / 'module_type_profiles'
+
+
+def load_initial_data(apps, schema_editor):
+ """
+ Load initial ModuleTypeProfile objects from file.
+ """
+ ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile')
+ initial_profiles = (
+ 'cpu',
+ 'fan',
+ 'gpu',
+ 'hard_disk',
+ 'memory',
+ 'power_supply'
+ )
+
+ for name in initial_profiles:
+ file_path = DATA_FILES_PATH / f'{name}.json'
+ with file_path.open('r') as f:
+ data = json.load(f)
+ try:
+ ModuleTypeProfile.objects.create(**data)
+ except Exception as e:
+ print(f"Error loading data from {file_path}")
+ raise e
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0205_moduletypeprofile'),
+ ]
+
+ operations = [
+ migrations.RunPython(load_initial_data),
+ ]
diff --git a/netbox/dcim/migrations/0207_remove_redundant_indexes.py b/netbox/dcim/migrations/0207_remove_redundant_indexes.py
new file mode 100644
index 000000000..b63e6423f
--- /dev/null
+++ b/netbox/dcim/migrations/0207_remove_redundant_indexes.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2b1 on 2025-04-03 18:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0206_load_module_type_profiles'),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name='cabletermination',
+ name='dcim_cablet_termina_884752_idx',
+ ),
+ ]
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json
new file mode 100644
index 000000000..255886c5e
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json
@@ -0,0 +1,20 @@
+{
+ "name": "CPU",
+ "schema": {
+ "properties": {
+ "architecture": {
+ "type": "string",
+ "title": "Architecture"
+ },
+ "speed": {
+ "type": "number",
+ "title": "Speed",
+ "description": "Clock speed in GHz"
+ },
+ "cores": {
+ "type": "integer",
+ "description": "Number of cores present"
+ }
+ }
+ }
+}
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json
new file mode 100644
index 000000000..e6a2a384e
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json
@@ -0,0 +1,12 @@
+{
+ "name": "Fan",
+ "schema": {
+ "properties": {
+ "rpm": {
+ "type": "integer",
+ "title": "RPM",
+ "description": "Fan speed (RPM)"
+ }
+ }
+ }
+}
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json
new file mode 100644
index 000000000..1725a4ab7
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json
@@ -0,0 +1,28 @@
+{
+ "name": "GPU",
+ "schema": {
+ "properties": {
+ "interface": {
+ "type": "string",
+ "enum": [
+ "PCIe 4.0",
+ "PCIe 4.0 x8",
+ "PCIe 4.0 x16",
+ "PCIe 5.0 x16"
+ ]
+ },
+ "gpu" : {
+ "type": "string",
+ "title": "GPU"
+ },
+ "memory": {
+ "type": "integer",
+ "title": "Memory (GB)",
+ "description": "Total memory capacity (in GB)"
+ }
+ },
+ "required": [
+ "memory"
+ ]
+ }
+}
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json
new file mode 100644
index 000000000..8d55cfde6
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json
@@ -0,0 +1,29 @@
+{
+ "name": "Hard disk",
+ "schema": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "title": "Disk type",
+ "enum": [
+ "HD",
+ "SSD",
+ "NVME"
+ ],
+ "default": "SSD"
+ },
+ "size": {
+ "type": "integer",
+ "title": "Size (GB)",
+ "description": "Raw disk capacity"
+ },
+ "speed": {
+ "type": "integer",
+ "title": "Speed (RPM)"
+ }
+ },
+ "required": [
+ "size"
+ ]
+ }
+}
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json
new file mode 100644
index 000000000..8346bfce9
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json
@@ -0,0 +1,36 @@
+{
+ "name": "Memory",
+ "schema": {
+ "properties": {
+ "class": {
+ "type": "string",
+ "title": "Memory class",
+ "enum": [
+ "DDR3",
+ "DDR4",
+ "DDR5"
+ ],
+ "default": "DDR5"
+ },
+ "size": {
+ "type": "integer",
+ "title": "Size (GB)",
+ "description": "Raw capacity of the module"
+ },
+ "data_rate": {
+ "type": "integer",
+ "title": "Data rate",
+ "description": "Speed in MT/s"
+ },
+ "ecc": {
+ "type": "boolean",
+ "title": "ECC",
+ "description": "Error-correcting code is enabled"
+ }
+ },
+ "required": [
+ "class",
+ "size"
+ ]
+ }
+}
diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json
new file mode 100644
index 000000000..ea060a889
--- /dev/null
+++ b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json
@@ -0,0 +1,34 @@
+{
+ "name": "Power supply",
+ "schema": {
+ "properties": {
+ "input_current": {
+ "type": "string",
+ "title": "Current type",
+ "enum": [
+ "AC",
+ "DC"
+ ],
+ "default": "AC"
+ },
+ "input_voltage": {
+ "type": "integer",
+ "title": "Voltage",
+ "default": 120
+ },
+ "wattage": {
+ "type": "integer",
+ "description": "Available output power (watts)"
+ },
+ "hot_swappable": {
+ "type": "boolean",
+ "title": "Hot-swappable",
+ "default": false
+ }
+ },
+ "required": [
+ "input_current",
+ "input_voltage"
+ ]
+ }
+}
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index d74f34828..33af25678 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -2,6 +2,7 @@ from .cables import *
from .device_component_templates import *
from .device_components import *
from .devices import *
+from .modules import *
from .power import *
from .racks import *
from .sites import *
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index 02658617b..0a28d5acb 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -261,7 +261,6 @@ class CableTermination(ChangeLoggedModel):
)
termination_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=CABLE_TERMINATION_MODELS,
on_delete=models.PROTECT,
related_name='+'
)
@@ -301,9 +300,6 @@ class CableTermination(ChangeLoggedModel):
class Meta:
ordering = ('cable', 'cable_end', 'pk')
- indexes = (
- models.Index(fields=('termination_type', 'termination_id')),
- )
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index b4f057711..e0b05b388 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -751,7 +751,6 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
)
component_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 632121dc2..4b44c5b4e 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -452,6 +452,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
+ status = models.CharField(
+ verbose_name=_('status'),
+ max_length=50,
+ choices=PowerOutletStatusChoices,
+ default=PowerOutletStatusChoices.STATUS_ENABLED
+ )
type = models.CharField(
verbose_name=_('type'),
max_length=50,
@@ -495,6 +501,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
_("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
)
+ def get_status_color(self):
+ return PowerOutletStatusChoices.colors.get(self.status)
+
#
# Interfaces
@@ -1268,7 +1277,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
)
component_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=MODULAR_COMPONENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 78ffe6b66..5988f8241 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -3,7 +3,7 @@ import yaml
from functools import cached_property
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -19,17 +19,19 @@ from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
+from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
-from netbox.models import OrganizationalModel, PrimaryModel
+from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import RenderConfigMixin
+from .modules import Module
__all__ = (
@@ -38,8 +40,6 @@ __all__ = (
'DeviceType',
'MACAddress',
'Manufacturer',
- 'Module',
- 'ModuleType',
'Platform',
'VirtualChassis',
'VirtualDeviceContext',
@@ -367,108 +367,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
-class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
- """
- A ModuleType represents a hardware element that can be installed within a device and which houses additional
- components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
- DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
- cannot, however house device bays or module bays.
- """
- manufacturer = models.ForeignKey(
- to='dcim.Manufacturer',
- on_delete=models.PROTECT,
- related_name='module_types'
- )
- model = models.CharField(
- verbose_name=_('model'),
- max_length=100
- )
- part_number = models.CharField(
- verbose_name=_('part number'),
- max_length=50,
- blank=True,
- help_text=_('Discrete part number (optional)')
- )
- airflow = models.CharField(
- verbose_name=_('airflow'),
- max_length=50,
- choices=ModuleAirflowChoices,
- blank=True,
- null=True
- )
-
- clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
- prerequisite_models = (
- 'dcim.Manufacturer',
- )
-
- class Meta:
- ordering = ('manufacturer', 'model')
- constraints = (
- models.UniqueConstraint(
- fields=('manufacturer', 'model'),
- name='%(app_label)s_%(class)s_unique_manufacturer_model'
- ),
- )
- verbose_name = _('module type')
- verbose_name_plural = _('module types')
-
- def __str__(self):
- return self.model
-
- @property
- def full_name(self):
- return f"{self.manufacturer} {self.model}"
-
- def to_yaml(self):
- data = {
- 'manufacturer': self.manufacturer.name,
- 'model': self.model,
- 'part_number': self.part_number,
- 'description': self.description,
- 'weight': float(self.weight) if self.weight is not None else None,
- 'weight_unit': self.weight_unit,
- 'comments': self.comments,
- }
-
- # Component templates
- if self.consoleporttemplates.exists():
- data['console-ports'] = [
- c.to_yaml() for c in self.consoleporttemplates.all()
- ]
- if self.consoleserverporttemplates.exists():
- data['console-server-ports'] = [
- c.to_yaml() for c in self.consoleserverporttemplates.all()
- ]
- if self.powerporttemplates.exists():
- data['power-ports'] = [
- c.to_yaml() for c in self.powerporttemplates.all()
- ]
- if self.poweroutlettemplates.exists():
- data['power-outlets'] = [
- c.to_yaml() for c in self.poweroutlettemplates.all()
- ]
- if self.interfacetemplates.exists():
- data['interfaces'] = [
- c.to_yaml() for c in self.interfacetemplates.all()
- ]
- if self.frontporttemplates.exists():
- data['front-ports'] = [
- c.to_yaml() for c in self.frontporttemplates.all()
- ]
- if self.rearporttemplates.exists():
- data['rear-ports'] = [
- c.to_yaml() for c in self.rearporttemplates.all()
- ]
-
- return yaml.dump(dict(data), sort_keys=False)
-
-
#
# Devices
#
-class DeviceRole(OrganizationalModel):
+class DeviceRole(NestedGroupModel):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
@@ -491,6 +394,8 @@ class DeviceRole(OrganizationalModel):
null=True
)
+ clone_fields = ('parent', 'description')
+
class Meta:
ordering = ('name',)
verbose_name = _('device role')
@@ -524,23 +429,6 @@ class Platform(OrganizationalModel):
verbose_name_plural = _('platforms')
-def update_interface_bridges(device, interface_templates, module=None):
- """
- Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
- and applies it to the actual interfaces.
- """
- for interface_template in interface_templates.exclude(bridge=None):
- interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
-
- if interface_template.bridge:
- interface.bridge = Interface.objects.get(
- device=device,
- name=interface_template.bridge.resolve_name(module=module)
- )
- interface.full_clean()
- interface.save()
-
-
class Device(
ContactsMixin,
ImageAttachmentsMixin,
@@ -721,6 +609,12 @@ class Device(
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
+ services = GenericRelation(
+ to='ipam.Service',
+ content_type_field='parent_object_type',
+ object_id_field='parent_object_id',
+ related_query_name='device',
+ )
# Counter fields
console_port_count = CounterCacheField(
@@ -1157,171 +1051,6 @@ class Device(
return round(total_weight / 1000, 2)
-class Module(PrimaryModel, ConfigContextModel):
- """
- A Module represents a field-installable component within a Device which may itself hold multiple device components
- (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
- """
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='modules'
- )
- module_bay = models.OneToOneField(
- to='dcim.ModuleBay',
- on_delete=models.CASCADE,
- related_name='installed_module'
- )
- module_type = models.ForeignKey(
- to='dcim.ModuleType',
- on_delete=models.PROTECT,
- related_name='instances'
- )
- status = models.CharField(
- verbose_name=_('status'),
- max_length=50,
- choices=ModuleStatusChoices,
- default=ModuleStatusChoices.STATUS_ACTIVE
- )
- serial = models.CharField(
- max_length=50,
- blank=True,
- verbose_name=_('serial number')
- )
- asset_tag = models.CharField(
- max_length=50,
- blank=True,
- null=True,
- unique=True,
- verbose_name=_('asset tag'),
- help_text=_('A unique tag used to identify this device')
- )
-
- clone_fields = ('device', 'module_type', 'status')
-
- class Meta:
- ordering = ('module_bay',)
- verbose_name = _('module')
- verbose_name_plural = _('modules')
-
- def __str__(self):
- return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
-
- def get_status_color(self):
- return ModuleStatusChoices.colors.get(self.status)
-
- def clean(self):
- super().clean()
-
- if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
- raise ValidationError(
- _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
- device=self.device
- )
- )
-
- # Check for recursion
- module = self
- module_bays = []
- modules = []
- while module:
- if module.pk in modules or module.module_bay.pk in module_bays:
- raise ValidationError(_("A module bay cannot belong to a module installed within it."))
- modules.append(module.pk)
- module_bays.append(module.module_bay.pk)
- module = module.module_bay.module if module.module_bay else None
-
- def save(self, *args, **kwargs):
- is_new = self.pk is None
-
- super().save(*args, **kwargs)
-
- adopt_components = getattr(self, '_adopt_components', False)
- disable_replication = getattr(self, '_disable_replication', False)
-
- # We skip adding components if the module is being edited or
- # both replication and component adoption is disabled
- if not is_new or (disable_replication and not adopt_components):
- return
-
- # Iterate all component types
- for templates, component_attribute, component_model in [
- ("consoleporttemplates", "consoleports", ConsolePort),
- ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
- ("interfacetemplates", "interfaces", Interface),
- ("powerporttemplates", "powerports", PowerPort),
- ("poweroutlettemplates", "poweroutlets", PowerOutlet),
- ("rearporttemplates", "rearports", RearPort),
- ("frontporttemplates", "frontports", FrontPort),
- ("modulebaytemplates", "modulebays", ModuleBay),
- ]:
- create_instances = []
- update_instances = []
-
- # Prefetch installed components
- installed_components = {
- component.name: component
- for component in getattr(self.device, component_attribute).filter(module__isnull=True)
- }
-
- # Get the template for the module type.
- for template in getattr(self.module_type, templates).all():
- template_instance = template.instantiate(device=self.device, module=self)
-
- if adopt_components:
- existing_item = installed_components.get(template_instance.name)
-
- # Check if there's a component with the same name already
- if existing_item:
- # Assign it to the module
- existing_item.module = self
- update_instances.append(existing_item)
- continue
-
- # Only create new components if replication is enabled
- if not disable_replication:
- create_instances.append(template_instance)
-
- # Set default values for any applicable custom fields
- if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
- for component in create_instances:
- component.custom_field_data = cf_defaults
-
- if component_model is not ModuleBay:
- component_model.objects.bulk_create(create_instances)
- # Emit the post_save signal for each newly created object
- for component in create_instances:
- post_save.send(
- sender=component_model,
- instance=component,
- created=True,
- raw=False,
- using='default',
- update_fields=None
- )
- else:
- # ModuleBays must be saved individually for MPTT
- for instance in create_instances:
- instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
- instance.save()
-
- update_fields = ['module']
- component_model.objects.bulk_update(update_instances, update_fields)
- # Emit the post_save signal for each updated object
- for component in update_instances:
- post_save.send(
- sender=component_model,
- instance=component,
- created=False,
- raw=False,
- using='default',
- update_fields=update_fields
- )
-
- # Interface bridges have to be set after interface instantiation
- update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
-
-
#
# Virtual chassis
#
@@ -1506,7 +1235,6 @@ class MACAddress(PrimaryModel):
)
assigned_object_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index a0fc15a25..127dfb9e5 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -3,7 +3,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
-from dcim.constants import LOCATION_SCOPE_TYPES
__all__ = (
'CachedScopeMixin',
@@ -44,7 +43,6 @@ class CachedScopeMixin(models.Model):
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT,
- limit_choices_to=models.Q(model__in=LOCATION_SCOPE_TYPES),
related_name='+',
blank=True,
null=True
diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py
new file mode 100644
index 000000000..c5830f1db
--- /dev/null
+++ b/netbox/dcim/models/modules.py
@@ -0,0 +1,362 @@
+import jsonschema
+import yaml
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models.signals import post_save
+from django.utils.translation import gettext_lazy as _
+from jsonschema.exceptions import ValidationError as JSONValidationError
+
+from dcim.choices import *
+from dcim.constants import MODULE_TOKEN
+from dcim.utils import update_interface_bridges
+from extras.models import ConfigContextModel, CustomField
+from netbox.models import PrimaryModel
+from netbox.models.features import ImageAttachmentsMixin
+from netbox.models.mixins import WeightMixin
+from utilities.jsonschema import validate_schema
+from utilities.string import title
+from .device_components import *
+
+__all__ = (
+ 'Module',
+ 'ModuleType',
+ 'ModuleTypeProfile',
+)
+
+
+class ModuleTypeProfile(PrimaryModel):
+ """
+ A profile which defines the attributes which can be set on one or more ModuleTypes.
+ """
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ schema = models.JSONField(
+ blank=True,
+ null=True,
+ verbose_name=_('schema')
+ )
+
+ clone_fields = ('schema',)
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('module type profile')
+ verbose_name_plural = _('module type profiles')
+
+ def __str__(self):
+ return self.name
+
+ def clean(self):
+ super().clean()
+
+ # Validate the schema definition
+ if self.schema is not None:
+ try:
+ validate_schema(self.schema)
+ except ValidationError as e:
+ raise ValidationError({
+ 'schema': e.message,
+ })
+
+
+class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
+ """
+ A ModuleType represents a hardware element that can be installed within a device and which houses additional
+ components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
+ DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
+ cannot, however house device bays or module bays.
+ """
+ profile = models.ForeignKey(
+ to='dcim.ModuleTypeProfile',
+ on_delete=models.PROTECT,
+ related_name='module_types',
+ blank=True,
+ null=True
+ )
+ manufacturer = models.ForeignKey(
+ to='dcim.Manufacturer',
+ on_delete=models.PROTECT,
+ related_name='module_types'
+ )
+ model = models.CharField(
+ verbose_name=_('model'),
+ max_length=100
+ )
+ part_number = models.CharField(
+ verbose_name=_('part number'),
+ max_length=50,
+ blank=True,
+ help_text=_('Discrete part number (optional)')
+ )
+ airflow = models.CharField(
+ verbose_name=_('airflow'),
+ max_length=50,
+ choices=ModuleAirflowChoices,
+ blank=True,
+ null=True
+ )
+ attribute_data = models.JSONField(
+ blank=True,
+ null=True,
+ verbose_name=_('attributes')
+ )
+
+ clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
+ prerequisite_models = (
+ 'dcim.Manufacturer',
+ )
+
+ class Meta:
+ ordering = ('profile', 'manufacturer', 'model')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('manufacturer', 'model'),
+ name='%(app_label)s_%(class)s_unique_manufacturer_model'
+ ),
+ )
+ verbose_name = _('module type')
+ verbose_name_plural = _('module types')
+
+ def __str__(self):
+ return self.model
+
+ @property
+ def full_name(self):
+ return f"{self.manufacturer} {self.model}"
+
+ @property
+ def attributes(self):
+ """
+ Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile.
+ """
+ if not self.attribute_data or self.profile is None or not self.profile.schema:
+ return {}
+ attrs = {}
+ for name, options in self.profile.schema.get('properties', {}).items():
+ key = options.get('title', title(name))
+ attrs[key] = self.attribute_data.get(name)
+ return dict(sorted(attrs.items()))
+
+ def clean(self):
+ super().clean()
+
+ # Validate any attributes against the assigned profile's schema
+ if self.profile:
+ try:
+ jsonschema.validate(self.attribute_data, schema=self.profile.schema)
+ except JSONValidationError as e:
+ raise ValidationError(_("Invalid schema: {error}").format(error=e))
+ else:
+ self.attribute_data = None
+
+ def to_yaml(self):
+ data = {
+ 'profile': self.profile.name if self.profile else None,
+ 'manufacturer': self.manufacturer.name,
+ 'model': self.model,
+ 'part_number': self.part_number,
+ 'description': self.description,
+ 'weight': float(self.weight) if self.weight is not None else None,
+ 'weight_unit': self.weight_unit,
+ 'comments': self.comments,
+ }
+
+ # Component templates
+ if self.consoleporttemplates.exists():
+ data['console-ports'] = [
+ c.to_yaml() for c in self.consoleporttemplates.all()
+ ]
+ if self.consoleserverporttemplates.exists():
+ data['console-server-ports'] = [
+ c.to_yaml() for c in self.consoleserverporttemplates.all()
+ ]
+ if self.powerporttemplates.exists():
+ data['power-ports'] = [
+ c.to_yaml() for c in self.powerporttemplates.all()
+ ]
+ if self.poweroutlettemplates.exists():
+ data['power-outlets'] = [
+ c.to_yaml() for c in self.poweroutlettemplates.all()
+ ]
+ if self.interfacetemplates.exists():
+ data['interfaces'] = [
+ c.to_yaml() for c in self.interfacetemplates.all()
+ ]
+ if self.frontporttemplates.exists():
+ data['front-ports'] = [
+ c.to_yaml() for c in self.frontporttemplates.all()
+ ]
+ if self.rearporttemplates.exists():
+ data['rear-ports'] = [
+ c.to_yaml() for c in self.rearporttemplates.all()
+ ]
+
+ return yaml.dump(dict(data), sort_keys=False)
+
+
+class Module(PrimaryModel, ConfigContextModel):
+ """
+ A Module represents a field-installable component within a Device which may itself hold multiple device components
+ (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
+ """
+ device = models.ForeignKey(
+ to='dcim.Device',
+ on_delete=models.CASCADE,
+ related_name='modules'
+ )
+ module_bay = models.OneToOneField(
+ to='dcim.ModuleBay',
+ on_delete=models.CASCADE,
+ related_name='installed_module'
+ )
+ module_type = models.ForeignKey(
+ to='dcim.ModuleType',
+ on_delete=models.PROTECT,
+ related_name='instances'
+ )
+ status = models.CharField(
+ verbose_name=_('status'),
+ max_length=50,
+ choices=ModuleStatusChoices,
+ default=ModuleStatusChoices.STATUS_ACTIVE
+ )
+ serial = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name=_('serial number')
+ )
+ asset_tag = models.CharField(
+ max_length=50,
+ blank=True,
+ null=True,
+ unique=True,
+ verbose_name=_('asset tag'),
+ help_text=_('A unique tag used to identify this device')
+ )
+
+ clone_fields = ('device', 'module_type', 'status')
+
+ class Meta:
+ ordering = ('module_bay',)
+ verbose_name = _('module')
+ verbose_name_plural = _('modules')
+
+ def __str__(self):
+ return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
+
+ def get_status_color(self):
+ return ModuleStatusChoices.colors.get(self.status)
+
+ def clean(self):
+ super().clean()
+
+ if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
+ raise ValidationError(
+ _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
+ device=self.device
+ )
+ )
+
+ # Check for recursion
+ module = self
+ module_bays = []
+ modules = []
+ while module:
+ if module.pk in modules or module.module_bay.pk in module_bays:
+ raise ValidationError(_("A module bay cannot belong to a module installed within it."))
+ modules.append(module.pk)
+ module_bays.append(module.module_bay.pk)
+ module = module.module_bay.module if module.module_bay else None
+
+ def save(self, *args, **kwargs):
+ is_new = self.pk is None
+
+ super().save(*args, **kwargs)
+
+ adopt_components = getattr(self, '_adopt_components', False)
+ disable_replication = getattr(self, '_disable_replication', False)
+
+ # We skip adding components if the module is being edited or
+ # both replication and component adoption is disabled
+ if not is_new or (disable_replication and not adopt_components):
+ return
+
+ # Iterate all component types
+ for templates, component_attribute, component_model in [
+ ("consoleporttemplates", "consoleports", ConsolePort),
+ ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
+ ("interfacetemplates", "interfaces", Interface),
+ ("powerporttemplates", "powerports", PowerPort),
+ ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+ ("rearporttemplates", "rearports", RearPort),
+ ("frontporttemplates", "frontports", FrontPort),
+ ("modulebaytemplates", "modulebays", ModuleBay),
+ ]:
+ create_instances = []
+ update_instances = []
+
+ # Prefetch installed components
+ installed_components = {
+ component.name: component
+ for component in getattr(self.device, component_attribute).filter(module__isnull=True)
+ }
+
+ # Get the template for the module type.
+ for template in getattr(self.module_type, templates).all():
+ template_instance = template.instantiate(device=self.device, module=self)
+
+ if adopt_components:
+ existing_item = installed_components.get(template_instance.name)
+
+ # Check if there's a component with the same name already
+ if existing_item:
+ # Assign it to the module
+ existing_item.module = self
+ update_instances.append(existing_item)
+ continue
+
+ # Only create new components if replication is enabled
+ if not disable_replication:
+ create_instances.append(template_instance)
+
+ # Set default values for any applicable custom fields
+ if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
+ for component in create_instances:
+ component.custom_field_data = cf_defaults
+
+ if component_model is not ModuleBay:
+ component_model.objects.bulk_create(create_instances)
+ # Emit the post_save signal for each newly created object
+ for component in create_instances:
+ post_save.send(
+ sender=component_model,
+ instance=component,
+ created=True,
+ raw=False,
+ using='default',
+ update_fields=None
+ )
+ else:
+ # ModuleBays must be saved individually for MPTT
+ for instance in create_instances:
+ instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
+ instance.save()
+
+ update_fields = ['module']
+ component_model.objects.bulk_update(update_instances, update_fields)
+ # Emit the post_save signal for each updated object
+ for component in update_instances:
+ post_save.send(
+ sender=component_model,
+ instance=component,
+ created=False,
+ raw=False,
+ using='default',
+ update_fields=update_fields
+ )
+
+ # Interface bridges have to be set after interface instantiation
+ update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index aee142e01..b15cd8b34 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -73,6 +73,12 @@ class RackBase(WeightMixin, PrimaryModel):
null=True,
help_text=_('Outer dimension of rack (width)')
)
+ outer_height = models.PositiveSmallIntegerField(
+ verbose_name=_('outer height'),
+ blank=True,
+ null=True,
+ help_text=_('Outer dimension of rack (height)')
+ )
outer_depth = models.PositiveSmallIntegerField(
verbose_name=_('outer depth'),
blank=True,
@@ -140,7 +146,7 @@ class RackType(RackBase):
)
clone_fields = (
- 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
+ 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
@@ -173,8 +179,8 @@ class RackType(RackBase):
super().clean()
# Validate outer dimensions and unit
- if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
- raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
+ if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit:
+ raise ValidationError(_("Must specify a unit when setting an outer dimension"))
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
@@ -188,7 +194,7 @@ class RackType(RackBase):
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
- if self.outer_width is None and self.outer_depth is None:
+ if not any([self.outer_width, self.outer_depth, self.outer_height]):
self.outer_unit = None
super().save(*args, **kwargs)
@@ -235,8 +241,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
"""
# Fields which cannot be set locally if a RackType is assigned
RACKTYPE_FIELDS = (
- 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
- 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight',
+ 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
+ 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight',
)
form_factor = models.CharField(
@@ -329,7 +335,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units',
- 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
+ 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight',
+ 'weight_unit',
)
prerequisite_models = (
'dcim.Site',
@@ -364,8 +371,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site))
# Validate outer dimensions and unit
- if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
- raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
+ if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit:
+ raise ValidationError(_("Must specify a unit when setting an outer dimension"))
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
@@ -414,7 +421,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
- if self.outer_width is None and self.outer_depth is None:
+ if not any([self.outer_width, self.outer_depth, self.outer_height]):
self.outer_unit = None
super().save(*args, **kwargs)
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
index 964880990..8ef6a1d44 100644
--- a/netbox/dcim/search.py
+++ b/netbox/dcim/search.py
@@ -145,6 +145,7 @@ class LocationIndex(SearchIndex):
('facility', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('site', 'status', 'tenant', 'facility', 'description')
@@ -183,6 +184,17 @@ class ModuleBayIndex(SearchIndex):
display_attrs = ('device', 'label', 'position', 'description')
+@register_search
+class ModuleTypeProfileIndex(SearchIndex):
+ model = models.ModuleTypeProfile
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('name', 'description')
+
+
@register_search
class ModuleTypeIndex(SearchIndex):
model = models.ModuleType
@@ -225,7 +237,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200),
('description', 500),
)
- display_attrs = ('device', 'label', 'type', 'description')
+ display_attrs = ('device', 'label', 'type', 'status', 'description')
@register_search
@@ -318,6 +330,7 @@ class RegionIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('parent', 'description')
@@ -344,6 +357,7 @@ class SiteGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('parent', 'description')
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 5320820cd..d58e4e376 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -59,7 +59,7 @@ MACADDRESS_COPY_BUTTON = """
#
class DeviceRoleTable(NetBoxTable):
- name = tables.Column(
+ name = columns.MPTTColumn(
verbose_name=_('Name'),
linkify=True
)
@@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
verbose_name=_('Power Port'),
linkify=True
)
+ status = columns.ChoiceFieldColumn(
+ verbose_name=_('Status'),
+ )
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:poweroutlet_list'
@@ -530,9 +533,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
- 'tags', 'created', 'last_updated',
+ 'tags', 'created', 'last_updated', 'status',
+ )
+ default_columns = (
+ 'pk', 'name', 'device', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description',
)
- default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description')
class DevicePowerOutletTable(PowerOutletTable):
@@ -550,9 +555,11 @@ class DevicePowerOutletTable(PowerOutletTable):
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg',
'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
+ 'status',
)
default_columns = (
- 'pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
+ 'pk', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', 'cable',
+ 'connection',
)
diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py
index 6bd0d53b5..52edea8b4 100644
--- a/netbox/dcim/tables/modules.py
+++ b/netbox/dcim/tables/modules.py
@@ -1,25 +1,64 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
-from dcim.models import Module, ModuleType
+from dcim.models import Module, ModuleType, ModuleTypeProfile
from netbox.tables import NetBoxTable, columns
-from .template_code import WEIGHT
+from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
__all__ = (
'ModuleTable',
+ 'ModuleTypeProfileTable',
'ModuleTypeTable',
)
+class ModuleTypeProfileTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ attributes = columns.TemplateColumn(
+ template_code=MODULETYPEPROFILE_ATTRIBUTES,
+ accessor=tables.A('schema__properties'),
+ orderable=False,
+ verbose_name=_('Attributes')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='dcim:moduletypeprofile_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = ModuleTypeProfile
+ fields = (
+ 'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'description', 'attributes',
+ )
+
+
class ModuleTypeTable(NetBoxTable):
- model = tables.Column(
- linkify=True,
- verbose_name=_('Module Type')
+ profile = tables.Column(
+ verbose_name=_('Profile'),
+ linkify=True
)
manufacturer = tables.Column(
verbose_name=_('Manufacturer'),
linkify=True
)
+ model = tables.Column(
+ linkify=True,
+ verbose_name=_('Module Type')
+ )
+ weight = columns.TemplateColumn(
+ verbose_name=_('Weight'),
+ template_code=WEIGHT,
+ order_by=('_abs_weight', 'weight_unit')
+ )
+ attributes = columns.DictColumn()
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
@@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
- weight = columns.TemplateColumn(
- verbose_name=_('Weight'),
- template_code=WEIGHT,
- order_by=('_abs_weight', 'weight_unit')
- )
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
- 'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
- 'created', 'last_updated',
+ 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
+ 'attributes', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'model', 'manufacturer', 'part_number',
+ 'pk', 'model', 'profile', 'manufacturer', 'part_number',
)
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index dbd99ca24..ee40056de 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -5,7 +5,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
-from .template_code import WEIGHT
+from .template_code import OUTER_UNIT, WEIGHT
__all__ = (
'RackTable',
@@ -62,12 +62,16 @@ class RackTypeTable(NetBoxTable):
template_code="{{ value }}U",
verbose_name=_('Height')
)
- outer_width = tables.TemplateColumn(
- template_code="{{ record.outer_width }} {{ record.outer_unit }}",
+ outer_width = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
verbose_name=_('Outer Width')
)
- outer_depth = tables.TemplateColumn(
- template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
+ outer_height = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
+ verbose_name=_('Outer Height')
+ )
+ outer_depth = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
verbose_name=_('Outer Depth')
)
weight = columns.TemplateColumn(
@@ -96,8 +100,8 @@ class RackTypeTable(NetBoxTable):
model = RackType
fields = (
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
- 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments',
- 'instance_count', 'tags', 'created', 'last_updated',
+ 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
+ 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count',
@@ -159,12 +163,16 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
- outer_width = tables.TemplateColumn(
- template_code="{{ record.outer_width }} {{ record.outer_unit }}",
+ outer_width = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
verbose_name=_('Outer Width')
)
- outer_depth = tables.TemplateColumn(
- template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
+ outer_height = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
+ verbose_name=_('Outer Height')
+ )
+ outer_depth = columns.TemplateColumn(
+ template_code=OUTER_UNIT,
verbose_name=_('Outer Depth')
)
weight = columns.TemplateColumn(
@@ -183,8 +191,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
- 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count',
- 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated',
+ 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments',
+ 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts',
+ 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height',
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index 5206bb755..0c9494d51 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -32,12 +32,15 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:region_list'
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = Region
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -59,12 +62,15 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = SiteGroup
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -157,12 +163,16 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
actions = columns.ActionsColumn(
extra_buttons=LOCATION_BUTTONS
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
- 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'vlangroup_count',
+ 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
+ 'vlangroup_count',
)
default_columns = (
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index 3b2a9b4c3..356f76750 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -109,6 +109,11 @@ LOCATION_BUTTONS = """
"""
+OUTER_UNIT = """
+{% load helpers %}
+{% if value %}{{ value }} {{ record.outer_unit }}{% endif %}
+"""
+
#
# Device component templatebuttons
#
@@ -563,3 +568,7 @@ MODULEBAY_BUTTONS = """
{% endif %}
{% endif %}
"""
+
+MODULETYPEPROFILE_ATTRIBUTES = """
+{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
+"""
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index b1ed4aca3..c3ac6053d 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -74,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Region 4',
'slug': 'region-4',
+ 'comments': 'this is region 4, not region 5',
},
{
'name': 'Region 5',
@@ -86,13 +87,14 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
]
bulk_update_data = {
'description': 'New description',
+ 'comments': 'New comments',
}
@classmethod
def setUpTestData(cls):
Region.objects.create(name='Region 1', slug='region-1')
- Region.objects.create(name='Region 2', slug='region-2')
+ Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?')
Region.objects.create(name='Region 3', slug='region-3')
@@ -103,26 +105,30 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Site Group 4',
'slug': 'site-group-4',
+ 'comments': '',
},
{
'name': 'Site Group 5',
'slug': 'site-group-5',
+ 'comments': 'not actually empty',
},
{
'name': 'Site Group 6',
'slug': 'site-group-6',
+ 'comments': 'Do I really exist?',
},
]
bulk_update_data = {
'description': 'New description',
+ 'comments': 'I do exist!',
}
@classmethod
def setUpTestData(cls):
SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
- SiteGroup.objects.create(name='Site Group 2', slug='site-group-2')
- SiteGroup.objects.create(name='Site Group 3', slug='site-group-3')
+ SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='')
+ SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
class SiteTest(APIViewTestCases.APIViewTestCase):
@@ -212,12 +218,14 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
name='Parent Location 1',
slug='parent-location-1',
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='First!'
),
Location.objects.create(
site=sites[1],
name='Parent Location 2',
slug='parent-location-2',
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='Second!'
),
)
@@ -227,6 +235,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
slug='location-1',
parent=parent_locations[0],
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='Third!'
)
Location.objects.create(
site=sites[0],
@@ -250,6 +259,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
+ 'comments': '',
},
{
'name': 'Test Location 5',
@@ -257,6 +267,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
+ 'comments': 'Somebody should check on this location',
},
{
'name': 'Test Location 6',
@@ -580,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
model = ModuleType
- brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
+ brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@@ -618,6 +629,70 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
]
+class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase):
+ model = ModuleTypeProfile
+ brief_fields = ['description', 'display', 'id', 'name', 'url']
+ SCHEMAS = [
+ {
+ "properties": {
+ "foo": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "boolean"
+ }
+ }
+ },
+ ]
+ create_data = [
+ {
+ 'name': 'Module Type Profile 4',
+ 'schema': SCHEMAS[0],
+ },
+ {
+ 'name': 'Module Type Profile 5',
+ 'schema': SCHEMAS[1],
+ },
+ {
+ 'name': 'Module Type Profile 6',
+ 'schema': SCHEMAS[2],
+ },
+ ]
+ bulk_update_data = {
+ 'description': 'New description',
+ 'comments': 'New comments',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+ module_type_profiles = (
+ ModuleTypeProfile(
+ name='Module Type Profile 1',
+ schema=cls.SCHEMAS[0]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 2',
+ schema=cls.SCHEMAS[1]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 3',
+ schema=cls.SCHEMAS[2]
+ ),
+ )
+ ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']
@@ -1138,7 +1213,9 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
model = DeviceRole
- brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+ brief_fields = [
+ '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'
+ ]
create_data = [
{
'name': 'Device Role 4',
@@ -1163,12 +1240,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
- roles = (
- DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
- DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
- DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'),
- )
- DeviceRole.objects.bulk_create(roles)
+ DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
+ DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00')
+ DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
class PlatformTest(APIViewTestCases.APIViewTestCase):
@@ -1241,7 +1315,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 3fa44927d..ba8d4203d 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
parent_regions = (
- Region(name='Region 1', slug='region-1', description='foobar1'),
- Region(name='Region 2', slug='region-2', description='foobar2'),
- Region(name='Region 3', slug='region-3', description='foobar3'),
+ Region(
+ name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that",
+ ),
+ Region(
+ name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more',
+ ),
+ Region(
+ name='Region 3', slug='region-3', description='foobar3', comments='could ever do'
+ ),
)
for region in parent_regions:
region.save()
@@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'there'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'q': 'hundred men could'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
def test_name(self):
params = {'name': ['Region 1', 'Region 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -148,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]),
SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]),
SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]),
- SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]),
+ SiteGroup(
+ name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group',
+ ),
)
for site_group in groups:
site_group.save()
child_groups = (
- SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]),
+ SiteGroup(
+ name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group',
+ ),
SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]),
SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]),
SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]),
@@ -168,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'this'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ params = {'q': 'child'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Site Group 1', 'Site Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -401,6 +425,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_PLANNED,
facility='Facility 1',
description='foobar1',
+ comments='',
),
Location(
name='Location 2A',
@@ -410,6 +435,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_STAGING,
facility='Facility 2',
description='foobar2',
+ comments='First comment!',
),
Location(
name='Location 3A',
@@ -419,6 +445,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_DECOMMISSIONING,
facility='Facility 3',
description='foobar3',
+ comments='_This_ is a **bold comment**',
),
)
for location in locations:
@@ -436,6 +463,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'this'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'q': 'comment'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_name(self):
params = {'name': ['Location 1', 'Location 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -551,6 +585,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
starting_unit=1,
desc_units=False,
outer_width=100,
+ outer_height=100,
outer_depth=100,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=100,
@@ -569,6 +604,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
starting_unit=2,
desc_units=False,
outer_width=200,
+ outer_height=200,
outer_depth=200,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=200,
@@ -587,6 +623,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
starting_unit=3,
desc_units=True,
outer_width=300,
+ outer_height=300,
outer_depth=300,
outer_unit=RackDimensionUnitChoices.UNIT_INCH,
mounting_depth=300,
@@ -647,6 +684,10 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'outer_width': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_outer_height(self):
+ params = {'outer_height': [100, 200]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_outer_depth(self):
params = {'outer_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -730,6 +771,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
starting_unit=1,
desc_units=False,
outer_width=100,
+ outer_height=100,
outer_depth=100,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=100,
@@ -748,6 +790,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
starting_unit=2,
desc_units=False,
outer_width=200,
+ outer_height=200,
outer_depth=200,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
mounting_depth=200,
@@ -797,6 +840,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
u_height=42,
desc_units=False,
outer_width=100,
+ outer_height=100,
outer_depth=100,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
weight=10,
@@ -820,6 +864,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
u_height=43,
desc_units=False,
outer_width=200,
+ outer_height=200,
outer_depth=200,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
weight=20,
@@ -843,6 +888,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
u_height=44,
desc_units=True,
outer_width=300,
+ outer_height=300,
outer_depth=300,
outer_unit=RackDimensionUnitChoices.UNIT_INCH,
weight=30,
@@ -923,6 +969,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'outer_width': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_outer_height(self):
+ params = {'outer_height': [100, 200]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_outer_depth(self):
params = {'outer_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1436,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all()
filterset = ModuleTypeFilterSet
+ ignore_fields = ['attribute_data']
+
+ PROFILE_SCHEMA = {
+ "properties": {
+ "string": {"type": "string"},
+ "integer": {"type": "integer"},
+ "number": {"type": "number"},
+ "boolean": {"type": "boolean"},
+ }
+ }
@classmethod
def setUpTestData(cls):
@@ -1446,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
)
Manufacturer.objects.bulk_create(manufacturers)
+ module_type_profiles = (
+ ModuleTypeProfile(
+ name='Module Type Profile 1',
+ schema=cls.PROFILE_SCHEMA
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 2',
+ schema=cls.PROFILE_SCHEMA
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 3',
+ schema=cls.PROFILE_SCHEMA
+ ),
+ )
+ ModuleTypeProfile.objects.bulk_create(module_type_profiles)
module_types = (
ModuleType(
@@ -1455,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight=10,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar1',
- airflow=ModuleAirflowChoices.FRONT_TO_REAR
+ airflow=ModuleAirflowChoices.FRONT_TO_REAR,
+ profile=module_type_profiles[0],
+ attribute_data={
+ 'string': 'string1',
+ 'integer': 1,
+ 'number': 1.0,
+ 'boolean': True,
+ }
),
ModuleType(
manufacturer=manufacturers[1],
@@ -1464,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight=20,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar2',
- airflow=ModuleAirflowChoices.REAR_TO_FRONT
+ airflow=ModuleAirflowChoices.REAR_TO_FRONT,
+ profile=module_type_profiles[1],
+ attribute_data={
+ 'string': 'string2',
+ 'integer': 2,
+ 'number': 2.0,
+ 'boolean_': False,
+ }
),
ModuleType(
manufacturer=manufacturers[2],
@@ -1472,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
part_number='Part Number 3',
weight=30,
weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
- description='foobar3'
+ description='foobar3',
+ profile=module_type_profiles[2],
+ attribute_data={
+ 'string': 'string3',
+ 'integer': 3,
+ 'number': 3.0,
+ 'boolean': None,
+ }
),
)
ModuleType.objects.bulk_create(module_types)
@@ -1591,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_profile(self):
+ profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2]
+ params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'profile': [profiles[0].name, profiles[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_profile_attributes(self):
+ params = {'attr_string': 'string1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'attr_integer': '1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'attr_number': '2.0'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'attr_boolean': 'true'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
+class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = ModuleTypeProfile.objects.all()
+ filterset = ModuleTypeProfileFilterSet
+ ignore_fields = ['schema']
+
+ SCHEMAS = [
+ {
+ "properties": {
+ "foo": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "boolean"
+ }
+ }
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+ module_type_profiles = (
+ ModuleTypeProfile(
+ name='Module Type Profile 1',
+ description='foobar1',
+ schema=cls.SCHEMAS[0]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 2',
+ description='foobar2 2',
+ schema=cls.SCHEMAS[1]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 3',
+ description='foobar3',
+ schema=cls.SCHEMAS[2]
+ ),
+ )
+ ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['Module Type Profile 1', 'Module Type Profile 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePortTemplate.objects.all()
@@ -2141,12 +2313,65 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
- roles = (
+ parent_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
- DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
+ DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False)
)
- DeviceRole.objects.bulk_create(roles)
+ for role in parent_roles:
+ role.save()
+
+ roles = (
+ DeviceRole(
+ name='Device Role 1A',
+ slug='device-role-1a',
+ color='aa0000',
+ vm_role=True,
+ parent=parent_roles[0]
+ ),
+ DeviceRole(
+ name='Device Role 2A',
+ slug='device-role-2a',
+ color='00aa00',
+ vm_role=True,
+ parent=parent_roles[1]
+ ),
+ DeviceRole(
+ name='Device Role 3A',
+ slug='device-role-3a',
+ color='0000aa',
+ vm_role=False,
+ parent=parent_roles[2]
+ )
+ )
+ for role in roles:
+ role.save()
+
+ child_roles = (
+ DeviceRole(
+ name='Device Role 1A1',
+ slug='device-role-1a1',
+ color='bb0000',
+ vm_role=True,
+ parent=roles[0]
+ ),
+ DeviceRole(
+ name='Device Role 2A1',
+ slug='device-role-2a1',
+ color='00bb00',
+ vm_role=True,
+ parent=roles[1]
+ ),
+ DeviceRole(
+ name='Device Role 3A1',
+ slug='device-role-3a1',
+ color='0000bb',
+ vm_role=False,
+ parent=roles[2]
+ )
+ )
+ for role in child_roles:
+ role.save()
def test_q(self):
params = {'q': 'foobar1'}
@@ -2166,14 +2391,28 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_vm_role(self):
params = {'vm_role': 'true'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'vm_role': 'false'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_parent(self):
+ roles = DeviceRole.objects.filter(parent__isnull=True)[:2]
+ params = {'parent_id': [roles[0].pk, roles[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'parent': [roles[0].slug, roles[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_ancestor(self):
+ roles = DeviceRole.objects.filter(parent__isnull=True)[:2]
+ params = {'ancestor_id': [roles[0].pk, roles[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'ancestor': [roles[0].slug, roles[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Platform.objects.all()
@@ -2259,7 +2498,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
platforms = (
Platform(name='Platform 1', slug='platform-1'),
@@ -2771,7 +3011,8 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3046,7 +3287,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3258,7 +3500,8 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3476,7 +3719,8 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3720,7 +3964,8 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3806,6 +4051,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
description='First',
color='ff0000',
+ status=PowerOutletStatusChoices.STATUS_ENABLED,
),
PowerOutlet(
device=devices[1],
@@ -3815,6 +4061,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B,
description='Second',
color='00ff00',
+ status=PowerOutletStatusChoices.STATUS_DISABLED,
),
PowerOutlet(
device=devices[2],
@@ -3824,6 +4071,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C,
description='Third',
color='0000ff',
+ status=PowerOutletStatusChoices.STATUS_FAULTY,
),
)
PowerOutlet.objects.bulk_create(power_outlets)
@@ -3918,6 +4166,23 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_status(self):
+ params = {'status': [PowerOutletStatusChoices.STATUS_ENABLED]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'status': [PowerOutletStatusChoices.STATUS_DISABLED]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'status': [PowerOutletStatusChoices.STATUS_FAULTY]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'status': [
+ PowerOutletStatusChoices.STATUS_ENABLED,
+ PowerOutletStatusChoices.STATUS_DISABLED,
+ PowerOutletStatusChoices.STATUS_FAULTY,
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all()
@@ -3965,7 +4230,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -4544,7 +4810,8 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -4816,7 +5083,8 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5056,7 +5324,8 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5228,7 +5497,8 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -5363,7 +5633,8 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
regions = (
Region(name='Region 1', slug='region-1'),
diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py
index 73d5dbd98..fa654f789 100644
--- a/netbox/dcim/tests/test_forms.py
+++ b/netbox/dcim/tests/test_forms.py
@@ -1,11 +1,8 @@
from django.test import TestCase
from dcim.choices import (
- DeviceFaceChoices,
- DeviceStatusChoices,
- InterfaceModeChoices,
- InterfaceTypeChoices,
- PortTypeChoices,
+ DeviceFaceChoices, DeviceStatusChoices, InterfaceModeChoices, InterfaceTypeChoices, PortTypeChoices,
+ PowerOutletStatusChoices,
)
from dcim.forms import *
from dcim.models import *
@@ -18,6 +15,56 @@ def get_id(model, slug):
return model.objects.get(slug=slug).id
+class PowerOutletFormTestCase(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.site = site = Site.objects.create(name='Site 1', slug='site-1')
+ cls.manufacturer = manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ cls.role = role = DeviceRole.objects.create(
+ name='Device Role 1', slug='device-role-1', color='ff0000'
+ )
+ cls.device_type = device_type = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
+ )
+ cls.rack = rack = Rack.objects.create(name='Rack 1', site=site)
+ cls.device = Device.objects.create(
+ name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1
+ )
+
+ def test_status_is_required(self):
+ form = PowerOutletForm(data={
+ 'device': self.device,
+ 'module': None,
+ 'name': 'New Enabled Outlet',
+ })
+ self.assertFalse(form.is_valid())
+ self.assertIn('status', form.errors)
+
+ def test_status_must_be_defined_choice(self):
+ form = PowerOutletForm(data={
+ 'device': self.device,
+ 'module': None,
+ 'name': 'New Enabled Outlet',
+ 'status': 'this isn\'t a defined choice',
+ })
+ self.assertFalse(form.is_valid())
+ self.assertIn('status', form.errors)
+ self.assertTrue(form.errors['status'][-1].startswith('Select a valid choice.'))
+
+ def test_status_recognizes_choices(self):
+ for index, choice in enumerate(PowerOutletStatusChoices.CHOICES):
+ form = PowerOutletForm(data={
+ 'device': self.device,
+ 'module': None,
+ 'name': f'New Enabled Outlet {index + 1}',
+ 'status': choice[0],
+ })
+ self.assertEqual({}, form.errors)
+ self.assertTrue(form.is_valid())
+ instance = form.save()
+ self.assertEqual(instance.status, choice[0])
+
+
class DeviceTestCase(TestCase):
@classmethod
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 398945f93..281071ed9 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -383,7 +383,8 @@ class DeviceTestCase(TestCase):
DeviceRole(name='Test Role 1', slug='test-role-1'),
DeviceRole(name='Test Role 2', slug='test-role-2'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
# Create a CustomField with a default value & assign it to all component models
cf1 = CustomField.objects.create(name='cf1', default='foo')
@@ -502,7 +503,8 @@ class DeviceTestCase(TestCase):
device=device,
name='Power Outlet 1',
power_port=powerport,
- feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
+ feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
+ status=PowerOutletStatusChoices.STATUS_ENABLED,
)
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index c786d1494..69192f0a1 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1,3 +1,4 @@
+import json
from decimal import Decimal
from zoneinfo import ZoneInfo
@@ -25,8 +26,10 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three Regions
regions = (
- Region(name='Region 1', slug='region-1'),
- Region(name='Region 2', slug='region-2'),
+ Region(name='Region 1', slug='region-1', comments=''),
+ Region(
+ name='Region 2', slug='region-2', comments="It's going to take a lot to drag me away from you"
+ ),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
@@ -40,13 +43,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': regions[2].pk,
'description': 'A new region',
'tags': [t.pk for t in tags],
+ 'comments': 'This comment is really exciting!',
}
cls.csv_data = (
- "name,slug,description",
- "Region 4,region-4,Fourth region",
- "Region 5,region-5,Fifth region",
- "Region 6,region-6,Sixth region",
+ "name,slug,description,comments",
+ "Region 4,region-4,Fourth region,",
+ "Region 5,region-5,Fifth region,hi guys",
+ "Region 6,region-6,Sixth region,bye guys",
)
cls.csv_update_data = (
@@ -58,6 +62,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'This comment is super exciting!!!',
}
@@ -69,7 +74,7 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three SiteGroups
sitegroups = (
- SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 1', slug='site-group-1', comments='Still here'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
@@ -84,24 +89,26 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': sitegroups[2].pk,
'description': 'A new site group',
'tags': [t.pk for t in tags],
+ 'comments': 'still here',
}
cls.csv_data = (
- "name,slug,description",
- "Site Group 4,site-group-4,Fourth site group",
- "Site Group 5,site-group-5,Fifth site group",
- "Site Group 6,site-group-6,Sixth site group",
+ "name,slug,description,comments",
+ "Site Group 4,site-group-4,Fourth site group,",
+ "Site Group 5,site-group-5,Fifth site group,still hear",
+ "Site Group 6,site-group-6,Sixth site group,"
)
cls.csv_update_data = (
- "id,name,description",
- f"{sitegroups[0].pk},Site Group 7,Fourth site group7",
- f"{sitegroups[1].pk},Site Group 8,Fifth site group8",
- f"{sitegroups[2].pk},Site Group 0,Sixth site group9",
+ "id,name,description,comments",
+ f"{sitegroups[0].pk},Site Group 7,Fourth site group7,",
+ f"{sitegroups[1].pk},Site Group 8,Fifth site group8,when will it end",
+ f"{sitegroups[2].pk},Site Group 0,Sixth site group9,",
)
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'the end',
}
@@ -202,6 +209,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='',
),
Location(
name='Location 2',
@@ -209,6 +217,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='First comment!',
),
Location(
name='Location 3',
@@ -216,6 +225,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='_This_ is a **bold comment**',
),
)
for location in locations:
@@ -232,24 +242,26 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tenant': tenant.pk,
'description': 'A new location',
'tags': [t.pk for t in tags],
+ 'comments': 'This comment is really boring',
}
cls.csv_data = (
- "site,tenant,name,slug,status,description",
- "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
- "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
- "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
+ "site,tenant,name,slug,status,description,comments",
+ "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location,",
+ "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location,",
+ "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location,hi!",
)
cls.csv_update_data = (
- "id,name,description",
- f"{locations[0].pk},Location 7,Fourth location7",
- f"{locations[1].pk},Location 8,Fifth location8",
- f"{locations[2].pk},Location 0,Sixth location9",
+ "id,name,description,comments",
+ f"{locations[0].pk},Location 7,Fourth location7,Useful comment",
+ f"{locations[1].pk},Location 8,Fifth location8,unuseful comment",
+ f"{locations[2].pk},Location 0,Sixth location9,",
)
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'This comment is also really boring',
}
@@ -1307,6 +1319,79 @@ module-bays:
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
+class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = ModuleTypeProfile
+
+ SCHEMAS = [
+ {
+ "properties": {
+ "foo": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "properties": {
+ "foo": {
+ "type": "boolean"
+ }
+ }
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+ module_type_profiles = (
+ ModuleTypeProfile(
+ name='Module Type Profile 1',
+ schema=cls.SCHEMAS[0]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 2',
+ schema=cls.SCHEMAS[1]
+ ),
+ ModuleTypeProfile(
+ name='Module Type Profile 3',
+ schema=cls.SCHEMAS[2]
+ ),
+ )
+ ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Module Type Profile X',
+ 'description': 'A new profile',
+ 'schema': json.dumps(cls.SCHEMAS[0]),
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,schema",
+ f"Module Type Profile 4,{json.dumps(cls.SCHEMAS[0])}",
+ f"Module Type Profile 5,{json.dumps(cls.SCHEMAS[1])}",
+ f"Module Type Profile 6,{json.dumps(cls.SCHEMAS[2])}",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{module_type_profiles[0].pk},New description",
+ f"{module_type_profiles[1].pk},New description",
+ f"{module_type_profiles[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
#
# DeviceType components
#
@@ -1696,13 +1781,16 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- roles = (
+ roles = [
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
- )
- DeviceRole.objects.bulk_create(roles)
+ DeviceRole(name='Device Role 4', slug='device-role-4'),
+ ]
+ for role in roles:
+ role.save()
+ roles.append(DeviceRole.objects.create(name='Device Role 5', slug='device-role-5', parent=roles[3]))
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
@@ -1726,6 +1814,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
f"{roles[0].pk},Device Role 7,New description7",
f"{roles[1].pk},Device Role 8,New description8",
f"{roles[2].pk},Device Role 9,New description9",
+ f"{roles[4].pk},Device Role 10,New description10",
)
cls.bulk_edit_data = {
@@ -1811,7 +1900,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
)
- DeviceRole.objects.bulk_create(roles)
+ for role in roles:
+ role.save()
platforms = (
Platform(name='Platform 1', slug='platform-1'),
@@ -2526,6 +2616,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'device': device.pk,
'name': 'Power Outlet X',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+ 'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
@@ -2536,6 +2627,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'device': device.pk,
'name': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+ 'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
@@ -2544,6 +2636,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_edit_data = {
'type': PowerOutletTypeChoices.TYPE_IEC_C15,
+ 'status': PowerOutletStatusChoices.STATUS_ENABLED,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description',
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index bcfd32707..122593834 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -37,6 +37,9 @@ urlpatterns = [
path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
path('device-types//', include(get_model_urls('dcim', 'devicetype'))),
+ path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
+ path('module-type-profiles//', include(get_model_urls('dcim', 'moduletypeprofile'))),
+
path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))),
path('module-types//', include(get_model_urls('dcim', 'moduletype'))),
diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py
index 4d4228490..0931761bf 100644
--- a/netbox/dcim/utils.py
+++ b/netbox/dcim/utils.py
@@ -1,3 +1,4 @@
+from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
@@ -56,3 +57,22 @@ def rebuild_paths(terminations):
for cp in cable_paths:
cp.delete()
create_cablepath(cp.origins)
+
+
+def update_interface_bridges(device, interface_templates, module=None):
+ """
+ Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
+ and applies it to the actual interfaces.
+ """
+ Interface = apps.get_model('dcim', 'Interface')
+
+ for interface_template in interface_templates.exclude(bridge=None):
+ interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+
+ if interface_template.bridge:
+ interface.bridge = Interface.objects.get(
+ device=device,
+ name=interface_template.bridge.resolve_name(module=module)
+ )
+ interface.full_clean()
+ interface.save()
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 4eb3cf016..faa9f6bb6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -17,12 +17,12 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
-from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related
from utilities.query_functions import CollateAsChar
+from utilities.request import safe_for_redirect
from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
@@ -313,11 +313,6 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable
-@register_model_view(Region, 'contacts')
-class RegionContactsView(ObjectContactsView):
- queryset = Region.objects.all()
-
-
#
# Site groups
#
@@ -444,11 +439,6 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteGroupTable
-@register_model_view(SiteGroup, 'contacts')
-class SiteGroupContactsView(ObjectContactsView):
- queryset = SiteGroup.objects.all()
-
-
#
# Sites
#
@@ -528,11 +518,6 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteTable
-@register_model_view(Site, 'contacts')
-class SiteContactsView(ObjectContactsView):
- queryset = Site.objects.all()
-
-
#
# Locations
#
@@ -643,11 +628,6 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
table = tables.LocationTable
-@register_model_view(Location, 'contacts')
-class LocationContactsView(ObjectContactsView):
- queryset = Location.objects.all()
-
-
#
# Rack roles
#
@@ -945,11 +925,6 @@ class RackBulkDeleteView(generic.BulkDeleteView):
table = tables.RackTable
-@register_model_view(Rack, 'contacts')
-class RackContactsView(ObjectContactsView):
- queryset = Rack.objects.all()
-
-
#
# Rack reservations
#
@@ -1088,11 +1063,6 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
table = tables.ManufacturerTable
-@register_model_view(Manufacturer, 'contacts')
-class ManufacturerContactsView(ObjectContactsView):
- queryset = Manufacturer.objects.all()
-
-
#
# Device types
#
@@ -1337,6 +1307,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
table = tables.DeviceTypeTable
+#
+# Module type profiles
+#
+
+@register_model_view(ModuleTypeProfile, 'list', path='', detail=False)
+class ModuleTypeProfileListView(generic.ObjectListView):
+ queryset = ModuleTypeProfile.objects.annotate(
+ instance_count=count_related(ModuleType, 'profile')
+ )
+ filterset = filtersets.ModuleTypeProfileFilterSet
+ filterset_form = forms.ModuleTypeProfileFilterForm
+ table = tables.ModuleTypeProfileTable
+
+
+@register_model_view(ModuleTypeProfile)
+class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+ queryset = ModuleTypeProfile.objects.all()
+
+
+@register_model_view(ModuleTypeProfile, 'add', detail=False)
+@register_model_view(ModuleTypeProfile, 'edit')
+class ModuleTypeProfileEditView(generic.ObjectEditView):
+ queryset = ModuleTypeProfile.objects.all()
+ form = forms.ModuleTypeProfileForm
+
+
+@register_model_view(ModuleTypeProfile, 'delete')
+class ModuleTypeProfileDeleteView(generic.ObjectDeleteView):
+ queryset = ModuleTypeProfile.objects.all()
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_import', detail=False)
+class ModuleTypeProfileBulkImportView(generic.BulkImportView):
+ queryset = ModuleTypeProfile.objects.all()
+ model_form = forms.ModuleTypeProfileImportForm
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_edit', path='edit', detail=False)
+class ModuleTypeProfileBulkEditView(generic.BulkEditView):
+ queryset = ModuleTypeProfile.objects.annotate(
+ instance_count=count_related(Module, 'module_type')
+ )
+ filterset = filtersets.ModuleTypeProfileFilterSet
+ table = tables.ModuleTypeProfileTable
+ form = forms.ModuleTypeProfileBulkEditForm
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
+class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
+ queryset = ModuleTypeProfile.objects.annotate(
+ instance_count=count_related(Module, 'module_type')
+ )
+ filterset = filtersets.ModuleTypeProfileFilterSet
+ table = tables.ModuleTypeProfileTable
+
+
#
# Module types
#
@@ -2381,11 +2407,6 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable
-@register_model_view(Device, 'contacts')
-class DeviceContactsView(ObjectContactsView):
- queryset = Device.objects.all()
-
-
#
# Modules
#
@@ -3793,7 +3814,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
)
))
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
return redirect(self.get_return_url(request, device))
@@ -3945,11 +3966,6 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
table = tables.PowerPanelTable
-@register_model_view(PowerPanel, 'contacts')
-class PowerPanelContactsView(ObjectContactsView):
- queryset = PowerPanel.objects.all()
-
-
#
# Power feeds
#
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 5e799b504..07540c50d 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -12,4 +12,5 @@ from .serializers_.configcontexts import *
from .serializers_.configtemplates import *
from .serializers_.savedfilters import *
from .serializers_.scripts import *
+from .serializers_.tableconfigs import *
from .serializers_.tags import *
diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py
index c4a683c74..69652907e 100644
--- a/netbox/extras/api/serializers_/configtemplates.py
+++ b/netbox/extras/api/serializers_/configtemplates.py
@@ -22,6 +22,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
model = ConfigTemplate
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
- 'data_source', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+ 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
+ 'data_synced', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py
index 11f502a02..0d19d642c 100644
--- a/netbox/extras/api/serializers_/exporttemplates.py
+++ b/netbox/extras/api/serializers_/exporttemplates.py
@@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = [
- 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
- 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
- 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params',
+ 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source',
+ 'data_path', 'data_file', 'data_synced', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
diff --git a/netbox/extras/api/serializers_/tableconfigs.py b/netbox/extras/api/serializers_/tableconfigs.py
new file mode 100644
index 000000000..7a4fb7b2a
--- /dev/null
+++ b/netbox/extras/api/serializers_/tableconfigs.py
@@ -0,0 +1,22 @@
+from core.models import ObjectType
+from extras.models import TableConfig
+from netbox.api.fields import ContentTypeField
+from netbox.api.serializers import ValidatedModelSerializer
+
+__all__ = (
+ 'TableConfigSerializer',
+)
+
+
+class TableConfigSerializer(ValidatedModelSerializer):
+ object_type = ContentTypeField(
+ queryset=ObjectType.objects.all()
+ )
+
+ class Meta:
+ model = TableConfig
+ fields = [
+ 'id', 'url', 'display_url', 'display', 'object_type', 'table', 'name', 'description', 'user', 'weight',
+ 'enabled', 'shared', 'columns', 'ordering', 'created', 'last_updated',
+ ]
+ brief_fields = ('id', 'url', 'display', 'name', 'description', 'object_type', 'table')
diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py
index e4e62845a..5dc39584f 100644
--- a/netbox/extras/api/serializers_/tags.py
+++ b/netbox/extras/api/serializers_/tags.py
@@ -1,10 +1,16 @@
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
from core.models import ObjectType
-from extras.models import Tag
+from extras.models import Tag, TaggedItem
+from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ContentTypeField, RelatedObjectCountField
-from netbox.api.serializers import ValidatedModelSerializer
+from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer
+from utilities.api import get_serializer_for_model
__all__ = (
'TagSerializer',
+ 'TaggedItemSerializer',
)
@@ -21,7 +27,41 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types',
- 'tagged_items', 'created', 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight',
+ 'object_types', 'tagged_items', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
+
+
+class TaggedItemSerializer(BaseModelSerializer):
+ object_type = ContentTypeField(
+ source='content_type',
+ read_only=True
+ )
+ object = serializers.SerializerMethodField(
+ read_only=True
+ )
+ tag = TagSerializer(
+ nested=True,
+ read_only=True
+ )
+
+ class Meta:
+ model = TaggedItem
+ fields = [
+ 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag',
+ ]
+ brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag')
+
+ @extend_schema_field(serializers.JSONField())
+ def get_object(self, obj):
+ """
+ Serialize a nested representation of the tagged object.
+ """
+ try:
+ serializer = get_serializer_for_model(obj.content_object)
+ except SerializerNotFound:
+ return obj.object_repr
+ data = serializer(obj.content_object, nested=True, context={'request': self.context['request']}).data
+
+ return data
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index bbcb8f0ef..101808753 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -14,11 +14,13 @@ router.register('custom-field-choice-sets', views.CustomFieldChoiceSetViewSet)
router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)
+router.register('table-configs', views.TableConfigViewSet)
router.register('bookmarks', views.BookmarkViewSet)
router.register('notifications', views.NotificationViewSet)
router.register('notification-groups', views.NotificationGroupViewSet)
router.register('subscriptions', views.SubscriptionViewSet)
router.register('tags', views.TagViewSet)
+router.register('tagged-objects', views.TaggedItemViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index e4c3c7f3e..6e9225f73 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -6,6 +6,7 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
+from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
@@ -20,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.request import copy_safe_request
from . import serializers
@@ -130,6 +131,17 @@ class SavedFilterViewSet(NetBoxModelViewSet):
filterset_class = filtersets.SavedFilterFilterSet
+#
+# Table Configs
+#
+
+class TableConfigViewSet(NetBoxModelViewSet):
+ metadata_class = ContentTypeMetadata
+ queryset = TableConfig.objects.all()
+ serializer_class = serializers.TableConfigSerializer
+ filterset_class = filtersets.TableConfigFilterSet
+
+
#
# Bookmarks
#
@@ -172,6 +184,12 @@ class TagViewSet(NetBoxModelViewSet):
filterset_class = filtersets.TagFilterSet
+class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
+ queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag')
+ serializer_class = serializers.TaggedItemSerializer
+ filterset_class = filtersets.TaggedItemFilterSet
+
+
#
# Image attachments
#
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index 8258f4aaf..d2a5d94e5 100644
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -199,23 +199,6 @@ class WebhookHttpMethodChoices(ChoiceSet):
)
-#
-# Staging
-#
-
-class ChangeActionChoices(ChoiceSet):
-
- ACTION_CREATE = 'create'
- ACTION_UPDATE = 'update'
- ACTION_DELETE = 'delete'
-
- CHOICES = (
- (ACTION_CREATE, _('Create'), 'green'),
- (ACTION_UPDATE, _('Update'), 'blue'),
- (ACTION_DELETE, _('Delete'), 'red'),
- )
-
-
#
# Dashboard widgets
#
diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py
index fadf59c25..94f0b25ad 100644
--- a/netbox/extras/constants.py
+++ b/netbox/extras/constants.py
@@ -4,6 +4,9 @@ from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
+# Template Export
+DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
+
# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'
diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
index c9cf9e037..2173dc786 100644
--- a/netbox/extras/dashboard/widgets.py
+++ b/netbox/extras/dashboard/widgets.py
@@ -18,6 +18,7 @@ from core.models import ObjectType
from extras.choices import BookmarkOrderingChoices
from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
+from utilities.proxy import resolve_proxies
from utilities.querydict import dict_to_querydict
from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import get_viewname
@@ -364,7 +365,7 @@ class RSSFeedWidget(DashboardWidget):
response = requests.get(
url=self.config['feed_url'],
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
- proxies=settings.HTTP_PROXIES,
+ proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
timeout=3
)
response.raise_for_status()
diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py
index de739aa59..d05800c22 100644
--- a/netbox/extras/filters.py
+++ b/netbox/extras/filters.py
@@ -4,6 +4,7 @@ from .models import Tag
__all__ = (
'TagFilter',
+ 'TagIDFilter',
)
@@ -20,3 +21,18 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
kwargs.setdefault('queryset', Tag.objects.all())
super().__init__(*args, **kwargs)
+
+
+class TagIDFilter(django_filters.ModelMultipleChoiceFilter):
+ """
+ Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=1&tag=2), the queryset is filtered
+ to objects matching all tags.
+ """
+ def __init__(self, *args, **kwargs):
+
+ kwargs.setdefault('field_name', 'tags__id')
+ kwargs.setdefault('to_field_name', 'id')
+ kwargs.setdefault('conjoined', True)
+ kwargs.setdefault('queryset', Tag.objects.all())
+
+ super().__init__(*args, **kwargs)
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 4f40ce500..6adad110d 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -8,10 +8,12 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
-from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+from utilities.filters import (
+ ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+)
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
-from .filters import TagFilter
+from .filters import TagFilter, TagIDFilter
from .models import *
__all__ = (
@@ -30,7 +32,9 @@ __all__ = (
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
+ 'TableConfigFilterSet',
'TagFilterSet',
+ 'TaggedItemFilterSet',
'WebhookFilterSet',
)
@@ -257,8 +261,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ExportTemplate
fields = (
- 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled',
- 'data_synced',
+ 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'auto_sync_enabled', 'data_synced',
)
def search(self, queryset, name, value):
@@ -266,7 +270,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
- Q(description__icontains=value)
+ Q(description__icontains=value) |
+ Q(file_name__icontains=value)
)
@@ -322,6 +327,59 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
+class TableConfigFilterSet(ChangeLoggedModelFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label=_('Search'),
+ )
+ object_type_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ObjectType.objects.all(),
+ field_name='object_type'
+ )
+ object_type = ContentTypeFilter(
+ field_name='object_type'
+ )
+ user_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=User.objects.all(),
+ label=_('User (ID)'),
+ )
+ user = django_filters.ModelMultipleChoiceFilter(
+ field_name='user__username',
+ queryset=User.objects.all(),
+ to_field_name='username',
+ label=_('User (name)'),
+ )
+ usable = django_filters.BooleanFilter(
+ method='_usable'
+ )
+
+ class Meta:
+ model = TableConfig
+ fields = ('id', 'name', 'description', 'table', 'enabled', 'shared', 'weight')
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(table__icontains=value)
+ )
+
+ def _usable(self, queryset, name, value):
+ """
+ Return only TableConfigs that are both enabled and are shared (or belong to the current user).
+ """
+ user = self.request.user if self.request else None
+ if not user or user.is_anonymous:
+ if value:
+ return queryset.filter(enabled=True, shared=True)
+ return queryset.filter(Q(enabled=False) | Q(shared=False))
+ if value:
+ return queryset.filter(enabled=True).filter(Q(shared=True) | Q(user=user))
+ return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user)))
+
+
class BookmarkFilterSet(BaseFilterSet):
created = django_filters.DateTimeFilter()
object_type_id = MultiValueNumberFilter()
@@ -449,7 +507,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = Tag
- fields = ('id', 'name', 'slug', 'color', 'description', 'object_types')
+ fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types')
def search(self, queryset, name, value):
if not value.strip():
@@ -492,6 +550,41 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
)
+class TaggedItemFilterSet(BaseFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label=_('Search'),
+ )
+ object_type = ContentTypeFilter(
+ field_name='content_type'
+ )
+ object_type_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=ContentType.objects.all(),
+ field_name='content_type_id'
+ )
+ tag_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Tag.objects.all()
+ )
+ tag = django_filters.ModelMultipleChoiceFilter(
+ field_name='tag__slug',
+ queryset=Tag.objects.all(),
+ to_field_name='slug',
+ )
+
+ class Meta:
+ model = TaggedItem
+ fields = ('id', 'object_id')
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(tag__name__icontains=value) |
+ Q(tag__slug__icontains=value) |
+ Q(tag__description__icontains=value)
+ )
+
+
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',
@@ -665,10 +758,14 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
label=_('Data file (ID)'),
)
tag = TagFilter()
+ tag_id = TagIDFilter()
class Meta:
model = ConfigTemplate
- fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced')
+ fields = (
+ 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'auto_sync_enabled', 'data_synced'
+ )
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index 30d06683b..c854a6c81 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -21,6 +21,7 @@ __all__ = (
'JournalEntryBulkEditForm',
'NotificationGroupBulkEditForm',
'SavedFilterBulkEditForm',
+ 'TableConfigBulkEditForm',
'TagBulkEditForm',
'WebhookBulkEditForm',
)
@@ -155,6 +156,10 @@ class ExportTemplateBulkEditForm(BulkEditForm):
max_length=50,
required=False
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
max_length=15,
@@ -166,7 +171,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
- nullable_fields = ('description', 'mime_type', 'file_extension')
+ nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm):
@@ -197,6 +202,34 @@ class SavedFilterBulkEditForm(BulkEditForm):
nullable_fields = ('description',)
+class TableConfigBulkEditForm(BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=TableConfig.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
+ enabled = forms.NullBooleanField(
+ label=_('Enabled'),
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+ shared = forms.NullBooleanField(
+ label=_('Shared'),
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
+
+ nullable_fields = ('description',)
+
+
class WebhookBulkEditForm(NetBoxModelBulkEditForm):
model = Webhook
@@ -275,6 +308,10 @@ class TagBulkEditForm(BulkEditForm):
max_length=200,
required=False
)
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
nullable_fields = ('description',)
@@ -313,8 +350,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm):
max_length=200,
required=False
)
+ mime_type = forms.CharField(
+ label=_('MIME type'),
+ max_length=50,
+ required=False
+ )
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
+ file_extension = forms.CharField(
+ label=_('File extension'),
+ max_length=15,
+ required=False
+ )
+ as_attachment = forms.NullBooleanField(
+ label=_('As attachment'),
+ required=False,
+ widget=BulkEditNullBooleanSelect()
+ )
- nullable_fields = ('description',)
+ nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class JournalEntryBulkEditForm(BulkEditForm):
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 35c1cbc22..5c62932e5 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta:
model = ExportTemplate
fields = (
- 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+ 'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'template_code',
)
@@ -153,7 +154,8 @@ class ConfigTemplateImportForm(CSVModelForm):
class Meta:
model = ConfigTemplate
fields = (
- 'name', 'description', 'environment_params', 'template_code', 'tags',
+ 'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'tags',
)
@@ -232,10 +234,14 @@ class EventRuleImportForm(NetBoxModelImportForm):
class TagImportForm(CSVModelForm):
slug = SlugField()
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
class Meta:
model = Tag
- fields = ('name', 'slug', 'color', 'description')
+ fields = ('name', 'slug', 'color', 'weight', 'description')
class JournalEntryImportForm(NetBoxModelImportForm):
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 9dc024128..27881f17a 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -31,6 +31,7 @@ __all__ = (
'LocalConfigContextFilterForm',
'NotificationGroupFilterForm',
'SavedFilterFilterForm',
+ 'TableConfigFilterForm',
'TagFilterForm',
'WebhookFilterForm',
)
@@ -164,9 +165,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
model = ExportTemplate
fieldsets = (
- FieldSet('q', 'filter_id'),
+ FieldSet('q', 'filter_id', 'object_type_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
- FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')),
+ FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -190,6 +191,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('MIME type')
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
required=False
@@ -251,6 +256,36 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
)
+class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
+ fieldsets = (
+ FieldSet('q', 'filter_id'),
+ FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
+ )
+ object_type_id = ContentTypeMultipleChoiceField(
+ label=_('Object types'),
+ queryset=ObjectType.objects.public(),
+ required=False
+ )
+ enabled = forms.NullBooleanField(
+ label=_('Enabled'),
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ shared = forms.NullBooleanField(
+ label=_('Shared'),
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
+
+
class WebhookFilterForm(NetBoxModelFilterSetForm):
model = Webhook
fieldsets = (
@@ -325,7 +360,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
FieldSet('q', 'filter_id', 'tag_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
- FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')),
+ FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
)
@@ -367,7 +402,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('Device types')
)
- role_id = DynamicModelMultipleChoiceField(
+ device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Roles')
@@ -414,6 +449,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
+ FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -429,6 +465,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
}
)
tag = TagFilterField(ConfigTemplate)
+ mime_type = forms.CharField(
+ required=False,
+ label=_('MIME type')
+ )
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
+ file_extension = forms.CharField(
+ label=_('File extension'),
+ required=False
+ )
+ as_attachment = forms.NullBooleanField(
+ label=_('As attachment'),
+ required=False,
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
class LocalConfigContextFilterForm(forms.Form):
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 299fff81b..5590dfa1a 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -2,6 +2,7 @@ import json
import re
from django import forms
+from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@@ -21,6 +22,7 @@ from utilities.forms.fields import (
)
from utilities.forms.rendering import FieldSet, ObjectAttribute
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
+from utilities.tables import get_table_for_model
from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
@@ -37,6 +39,7 @@ __all__ = (
'NotificationGroupForm',
'SavedFilterForm',
'SubscriptionForm',
+ 'TableConfigForm',
'TagForm',
'WebhookForm',
)
@@ -260,7 +263,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
fieldsets = (
FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
- FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')),
+ FieldSet(
+ 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
+ ),
)
class Meta:
@@ -313,6 +318,65 @@ class SavedFilterForm(forms.ModelForm):
super().__init__(*args, initial=initial, **kwargs)
+class TableConfigForm(forms.ModelForm):
+ object_type = ContentTypeChoiceField(
+ label=_('Object type'),
+ queryset=ObjectType.objects.all()
+ )
+ ordering = SimpleArrayField(
+ base_field=forms.CharField(),
+ required=False,
+ label=_('Ordering'),
+ help_text=_(
+ "Enter a comma-separated list of column names. Prepend a name with a hyphen to reverse the order."
+ )
+ )
+ available_columns = SimpleArrayField(
+ base_field=forms.CharField(),
+ required=False,
+ widget=forms.SelectMultiple(
+ attrs={'size': 10, 'class': 'form-select'}
+ ),
+ label=_('Available Columns')
+ )
+ columns = SimpleArrayField(
+ base_field=forms.CharField(),
+ widget=forms.SelectMultiple(
+ attrs={'size': 10, 'class': 'form-select select-all'}
+ ),
+ label=_('Selected Columns')
+ )
+
+ class Meta:
+ model = TableConfig
+ exclude = ('user',)
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ object_type = ObjectType.objects.get(pk=get_field_value(self, 'object_type'))
+ model = object_type.model_class()
+ table_name = get_field_value(self, 'table')
+ table_class = get_table_for_model(model, table_name)
+ table = table_class([])
+
+ if columns := self._get_columns():
+ table._set_columns(columns)
+
+ # Initialize columns field based on table attributes
+ self.fields['available_columns'].widget.choices = table.available_columns
+ self.fields['columns'].widget.choices = table.selected_columns
+
+ def _get_columns(self):
+ if self.is_bound and (columns := self.data.getlist('columns')):
+ return columns
+ if 'columns' in self.initial:
+ columns = self.get_initial_for_field(self.fields['columns'], 'columns')
+ return columns.split(',') if type(columns) is str else columns
+ if self.instance is not None:
+ return self.instance.columns
+
+
class BookmarkForm(forms.ModelForm):
object_type = ContentTypeChoiceField(
label=_('Object type'),
@@ -504,15 +568,19 @@ class TagForm(forms.ModelForm):
queryset=ObjectType.objects.with_feature('tags'),
required=False
)
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
fieldsets = (
- FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')),
+ FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
)
class Meta:
model = Tag
fields = [
- 'name', 'slug', 'color', 'description', 'object_types',
+ 'name', 'slug', 'color', 'weight', 'description', 'object_types',
]
@@ -641,9 +709,11 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm):
)
fieldsets = (
- FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')),
- FieldSet('template_code', name=_('Content')),
+ FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
+ FieldSet(
+ 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering')
+ ),
)
class Meta:
diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py
index 8ac476544..c237b9991 100644
--- a/netbox/extras/forms/scripts.py
+++ b/netbox/extras/forms/scripts.py
@@ -1,11 +1,18 @@
+import os
+
from django import forms
+from django.conf import settings
+from django.core.files.storage import storages
from django.utils.translation import gettext_lazy as _
from core.choices import JobIntervalChoices
-from utilities.forms.widgets import DateTimePicker, NumberWithOptions
+from core.forms import ManagedFileForm
+from extras.storage import ScriptFileSystemStorage
from utilities.datetime import local_now
+from utilities.forms.widgets import DateTimePicker, NumberWithOptions
__all__ = (
+ 'ScriptFileForm',
'ScriptForm',
)
@@ -55,3 +62,26 @@ class ScriptForm(forms.Form):
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data
+
+
+class ScriptFileForm(ManagedFileForm):
+ """
+ ManagedFileForm with a custom save method to use django-storages.
+ """
+ def save(self, *args, **kwargs):
+ # If a file was uploaded, save it to disk
+ if self.cleaned_data['upload_file']:
+ storage = storages.create_storage(storages.backends["scripts"])
+
+ filename = self.cleaned_data['upload_file'].name
+ if isinstance(storage, ScriptFileSystemStorage):
+ full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
+ else:
+ full_path = filename
+
+ self.instance.file_path = full_path
+ data = self.cleaned_data['upload_file']
+ storage.save(filename, data)
+
+ # need to skip ManagedFileForm save method
+ return super(ManagedFileForm, self).save(*args, **kwargs)
diff --git a/netbox/extras/graphql/enums.py b/netbox/extras/graphql/enums.py
new file mode 100644
index 000000000..f841fa1f5
--- /dev/null
+++ b/netbox/extras/graphql/enums.py
@@ -0,0 +1,26 @@
+import strawberry
+
+from extras.choices import *
+
+__all__ = (
+ 'CustomFieldChoiceSetBaseEnum',
+ 'CustomFieldFilterLogicEnum',
+ 'CustomFieldTypeEnum',
+ 'CustomFieldUIEditableEnum',
+ 'CustomFieldUIVisibleEnum',
+ 'CustomLinkButtonClassEnum',
+ 'EventRuleActionEnum',
+ 'JournalEntryKindEnum',
+ 'WebhookHttpMethodEnum',
+)
+
+
+CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum())
+CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum(prefix='filter'))
+CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum(prefix='type'))
+CustomFieldUIEditableEnum = strawberry.enum(CustomFieldUIEditableChoices.as_enum())
+CustomFieldUIVisibleEnum = strawberry.enum(CustomFieldUIVisibleChoices.as_enum())
+CustomLinkButtonClassEnum = strawberry.enum(CustomLinkButtonClassChoices.as_enum())
+EventRuleActionEnum = strawberry.enum(EventRuleActionChoices.as_enum())
+JournalEntryKindEnum = strawberry.enum(JournalEntryKindChoices.as_enum(prefix='kind'))
+WebhookHttpMethodEnum = strawberry.enum(WebhookHttpMethodChoices.as_enum())
diff --git a/netbox/extras/graphql/filter_mixins.py b/netbox/extras/graphql/filter_mixins.py
new file mode 100644
index 000000000..7e9a970f2
--- /dev/null
+++ b/netbox/extras/graphql/filter_mixins.py
@@ -0,0 +1,52 @@
+from dataclasses import dataclass
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+from strawberry_django import FilterLookup
+
+from core.graphql.filter_mixins import BaseFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import JSONFilter
+ from .filters import *
+
+__all__ = (
+ 'CustomFieldsFilterMixin',
+ 'JournalEntriesFilterMixin',
+ 'TagsFilterMixin',
+ 'ConfigContextFilterMixin',
+ 'TagBaseFilterMixin',
+)
+
+
+@dataclass
+class CustomFieldsFilterMixin(BaseFilterMixin):
+ custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class JournalEntriesFilterMixin(BaseFilterMixin):
+ journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class TagsFilterMixin(BaseFilterMixin):
+ tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ConfigContextFilterMixin(BaseFilterMixin):
+ local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class TagBaseFilterMixin(BaseFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py
index ff2e6a0f1..2798c4896 100644
--- a/netbox/extras/graphql/filters.py
+++ b/netbox/extras/graphql/filters.py
@@ -1,7 +1,26 @@
-import strawberry_django
+from typing import Annotated, TYPE_CHECKING
-from extras import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import strawberry
+import strawberry_django
+from strawberry.scalars import ID
+from strawberry_django import FilterLookup
+
+from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from extras import models
+from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
+from netbox.graphql.filter_mixins import SyncedDataFilterMixin
+
+if TYPE_CHECKING:
+ from core.graphql.filters import ContentTypeFilter
+ from dcim.graphql.filters import (
+ DeviceRoleFilter, DeviceTypeFilter, LocationFilter, PlatformFilter, RegionFilter, SiteFilter, SiteGroupFilter,
+ )
+ from tenancy.graphql.filters import TenantFilter, TenantGroupFilter
+ from netbox.graphql.enums import ColorEnum
+ from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter
+ from users.graphql.filters import GroupFilter, UserFilter
+ from virtualization.graphql.filters import ClusterFilter, ClusterGroupFilter, ClusterTypeFilter
+ from .enums import *
__all__ = (
'ConfigContextFilter',
@@ -15,84 +34,291 @@ __all__ = (
'JournalEntryFilter',
'NotificationGroupFilter',
'SavedFilterFilter',
+ 'TableConfigFilter',
'TagFilter',
'WebhookFilter',
)
@strawberry_django.filter(models.ConfigContext, lookups=True)
-@autotype_decorator(filtersets.ConfigContextFilterSet)
-class ConfigContextFilter(BaseFilterMixin):
- pass
+class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] = strawberry_django.filter_field()
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ description: FilterLookup[str] = strawberry_django.filter_field()
+ is_active: FilterLookup[bool] = strawberry_django.filter_field()
+ regions: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ site_groups: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ site_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ sites: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ locations: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ device_types: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ roles: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ platforms: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ cluster_types: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ cluster_groups: Annotated['ClusterGroupFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ clusters: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ tenant_groups: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ tenants: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
+ data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ConfigTemplate, lookups=True)
-@autotype_decorator(filtersets.ConfigTemplateFilterSet)
-class ConfigTemplateFilter(BaseFilterMixin):
- pass
+class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ template_code: FilterLookup[str] | None = strawberry_django.filter_field()
+ environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
+ file_name: FilterLookup[str] | None = strawberry_django.filter_field()
+ file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
+ as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomField, lookups=True)
-@autotype_decorator(filtersets.CustomFieldFilterSet)
-class CustomFieldFilter(BaseFilterMixin):
- pass
+class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ object_types: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ label: FilterLookup[str] | None = strawberry_django.filter_field()
+ group_name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ required: FilterLookup[bool] | None = strawberry_django.filter_field()
+ unique: FilterLookup[bool] | None = strawberry_django.filter_field()
+ search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ related_object_filter: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ validation_minimum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ validation_maximum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ validation_regex: FilterLookup[str] | None = strawberry_django.filter_field()
+ choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ choice_set_id: ID | None = strawberry_django.filter_field()
+ ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field()
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True)
-@autotype_decorator(filtersets.CustomFieldChoiceSetFilterSet)
-class CustomFieldChoiceSetFilter(BaseFilterMixin):
- pass
+class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.CustomLink, lookups=True)
-@autotype_decorator(filtersets.CustomLinkFilterSet)
-class CustomLinkFilter(BaseFilterMixin):
- pass
+class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ link_text: FilterLookup[str] | None = strawberry_django.filter_field()
+ link_url: FilterLookup[str] | None = strawberry_django.filter_field()
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_name: FilterLookup[str] | None = strawberry_django.filter_field()
+ button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ExportTemplate, lookups=True)
-@autotype_decorator(filtersets.ExportTemplateFilterSet)
-class ExportTemplateFilter(BaseFilterMixin):
- pass
+class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ template_code: FilterLookup[str] | None = strawberry_django.filter_field()
+ environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ mime_type: FilterLookup[str] | None = strawberry_django.filter_field()
+ file_name: FilterLookup[str] | None = strawberry_django.filter_field()
+ file_extension: FilterLookup[str] | None = strawberry_django.filter_field()
+ as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ImageAttachment, lookups=True)
-@autotype_decorator(filtersets.ImageAttachmentFilterSet)
-class ImageAttachmentFilter(BaseFilterMixin):
- pass
+class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ object_id: ID | None = strawberry_django.filter_field()
+ image_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.JournalEntry, lookups=True)
-@autotype_decorator(filtersets.JournalEntryFilterSet)
-class JournalEntryFilter(BaseFilterMixin):
- pass
+class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+ assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_type_id: ID | None = strawberry_django.filter_field()
+ assigned_object_id: ID | None = strawberry_django.filter_field()
+ created_by: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.NotificationGroup, lookups=True)
-@autotype_decorator(filtersets.NotificationGroupFilterSet)
-class NotificationGroupFilter(BaseFilterMixin):
- pass
+class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+ users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.SavedFilter, lookups=True)
-@autotype_decorator(filtersets.SavedFilterFilterSet)
-class SavedFilterFilter(BaseFilterMixin):
- pass
+class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+ user_id: ID | None = strawberry_django.filter_field()
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ shared: FilterLookup[bool] | None = strawberry_django.filter_field()
+ parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@strawberry_django.filter(models.TableConfig, lookups=True)
+class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+ user_id: ID | None = strawberry_django.filter_field()
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ shared: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Tag, lookups=True)
-@autotype_decorator(filtersets.TagFilterSet)
-class TagFilter(BaseFilterMixin):
- pass
+class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
+ color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Webhook, lookups=True)
-@autotype_decorator(filtersets.WebhookFilterSet)
-class WebhookFilter(BaseFilterMixin):
- pass
+class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
+ http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ http_content_type: FilterLookup[str] | None = strawberry_django.filter_field()
+ additional_headers: FilterLookup[str] | None = strawberry_django.filter_field()
+ body_template: FilterLookup[str] | None = strawberry_django.filter_field()
+ secret: FilterLookup[str] | None = strawberry_django.filter_field()
+ ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field()
+ ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field()
+ events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.EventRule, lookups=True)
-@autotype_decorator(filtersets.EventRuleFilterSet)
-class EventRuleFilter(BaseFilterMixin):
- pass
+class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ action_object_type: FilterLookup[str] | None = strawberry_django.filter_field()
+ action_object_type_id: ID | None = strawberry_django.filter_field()
+ action_object_id: ID | None = strawberry_django.filter_field()
+ action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py
index 7d2d11bf1..947ff0b00 100644
--- a/netbox/extras/graphql/schema.py
+++ b/netbox/extras/graphql/schema.py
@@ -32,6 +32,9 @@ class ExtrasQuery:
saved_filter: SavedFilterType = strawberry_django.field()
saved_filter_list: List[SavedFilterType] = strawberry_django.field()
+ table_config: TableConfigType = strawberry_django.field()
+ table_config_list: List[TableConfigType] = strawberry_django.field()
+
journal_entry: JournalEntryType = strawberry_django.field()
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py
index a53c7bed3..4bd836f6b 100644
--- a/netbox/extras/graphql/types.py
+++ b/netbox/extras/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, TYPE_CHECKING
import strawberry
import strawberry_django
@@ -8,6 +8,22 @@ from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType
from .filters import *
+if TYPE_CHECKING:
+ from core.graphql.types import DataFileType, DataSourceType
+ from dcim.graphql.types import (
+ DeviceRoleType,
+ DeviceType,
+ DeviceTypeType,
+ LocationType,
+ PlatformType,
+ RegionType,
+ SiteGroupType,
+ SiteType,
+ )
+ from tenancy.graphql.types import TenantGroupType, TenantType
+ from users.graphql.types import GroupType, UserType
+ from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType
+
__all__ = (
'ConfigContextType',
'ConfigTemplateType',
@@ -22,6 +38,7 @@ __all__ = (
'NotificationType',
'SavedFilterType',
'SubscriptionType',
+ 'TableConfigType',
'TagType',
'WebhookType',
)
@@ -30,12 +47,12 @@ __all__ = (
@strawberry_django.type(
models.ConfigContext,
fields='__all__',
- filters=ConfigContextFilter
+ filters=ConfigContextFilter,
+ pagination=True
)
class ConfigContextType(ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None
-
roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]]
tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]]
@@ -54,7 +71,8 @@ class ConfigContextType(ObjectType):
@strawberry_django.type(
models.ConfigTemplate,
fields='__all__',
- filters=ConfigTemplateFilter
+ filters=ConfigTemplateFilter,
+ pagination=True
)
class ConfigTemplateType(TagsMixin, ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
@@ -69,7 +87,8 @@ class ConfigTemplateType(TagsMixin, ObjectType):
@strawberry_django.type(
models.CustomField,
fields='__all__',
- filters=CustomFieldFilter
+ filters=CustomFieldFilter,
+ pagination=True
)
class CustomFieldType(ObjectType):
related_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
@@ -78,8 +97,9 @@ class CustomFieldType(ObjectType):
@strawberry_django.type(
models.CustomFieldChoiceSet,
- exclude=('extra_choices', ),
- filters=CustomFieldChoiceSetFilter
+ exclude=['extra_choices'],
+ filters=CustomFieldChoiceSetFilter,
+ pagination=True
)
class CustomFieldChoiceSetType(ObjectType):
@@ -90,7 +110,8 @@ class CustomFieldChoiceSetType(ObjectType):
@strawberry_django.type(
models.CustomLink,
fields='__all__',
- filters=CustomLinkFilter
+ filters=CustomLinkFilter,
+ pagination=True
)
class CustomLinkType(ObjectType):
pass
@@ -99,7 +120,8 @@ class CustomLinkType(ObjectType):
@strawberry_django.type(
models.ExportTemplate,
fields='__all__',
- filters=ExportTemplateFilter
+ filters=ExportTemplateFilter,
+ pagination=True
)
class ExportTemplateType(ObjectType):
data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None
@@ -109,7 +131,8 @@ class ExportTemplateType(ObjectType):
@strawberry_django.type(
models.ImageAttachment,
fields='__all__',
- filters=ImageAttachmentFilter
+ filters=ImageAttachmentFilter,
+ pagination=True
)
class ImageAttachmentType(BaseObjectType):
object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
@@ -118,7 +141,8 @@ class ImageAttachmentType(BaseObjectType):
@strawberry_django.type(
models.JournalEntry,
fields='__all__',
- filters=JournalEntryFilter
+ filters=JournalEntryFilter,
+ pagination=True
)
class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
assigned_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
@@ -128,6 +152,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
@strawberry_django.type(
models.Notification,
# filters=NotificationFilter
+ pagination=True
)
class NotificationType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@@ -135,7 +160,8 @@ class NotificationType(ObjectType):
@strawberry_django.type(
models.NotificationGroup,
- filters=NotificationGroupFilter
+ filters=NotificationGroupFilter,
+ pagination=True
)
class NotificationGroupType(ObjectType):
users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]]
@@ -145,7 +171,8 @@ class NotificationGroupType(ObjectType):
@strawberry_django.type(
models.SavedFilter,
exclude=['content_types',],
- filters=SavedFilterFilter
+ filters=SavedFilterFilter,
+ pagination=True
)
class SavedFilterType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@@ -154,15 +181,27 @@ class SavedFilterType(ObjectType):
@strawberry_django.type(
models.Subscription,
# filters=NotificationFilter
+ pagination=True
)
class SubscriptionType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
+@strawberry_django.type(
+ models.TableConfig,
+ fields='__all__',
+ filters=TableConfigFilter,
+ pagination=True
+)
+class TableConfigType(ObjectType):
+ user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
+
+
@strawberry_django.type(
models.Tag,
exclude=['extras_taggeditem_items', ],
- filters=TagFilter
+ filters=TagFilter,
+ pagination=True
)
class TagType(ObjectType):
color: str
@@ -173,7 +212,8 @@ class TagType(ObjectType):
@strawberry_django.type(
models.Webhook,
exclude=['content_types',],
- filters=WebhookFilter
+ filters=WebhookFilter,
+ pagination=True
)
class WebhookType(OrganizationalObjectType):
pass
@@ -182,7 +222,8 @@ class WebhookType(OrganizationalObjectType):
@strawberry_django.type(
models.EventRule,
exclude=['content_types',],
- filters=EventRuleFilter
+ filters=EventRuleFilter,
+ pagination=True
)
class EventRuleType(OrganizationalObjectType):
action_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py
index aafba8c0d..b8c7eab7d 100644
--- a/netbox/extras/management/commands/housekeeping.py
+++ b/netbox/extras/management/commands/housekeeping.py
@@ -10,6 +10,7 @@ from packaging import version
from core.models import Job, ObjectChange
from netbox.config import Config
+from utilities.proxy import resolve_proxies
class Command(BaseCommand):
@@ -106,7 +107,7 @@ class Command(BaseCommand):
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers=headers,
- proxies=settings.HTTP_PROXIES
+ proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
)
response.raise_for_status()
diff --git a/netbox/extras/migrations/0002_squashed_0059.py b/netbox/extras/migrations/0002_squashed_0059.py
index b664b286e..3aa7644fd 100644
--- a/netbox/extras/migrations/0002_squashed_0059.py
+++ b/netbox/extras/migrations/0002_squashed_0059.py
@@ -3,10 +3,10 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0002_auto_20160622_1821'),
- ('extras', '0001_initial'),
- ('virtualization', '0001_virtualization'),
- ('tenancy', '0001_initial'),
+ ('dcim', '0002_squashed'),
+ ('extras', '0001_squashed'),
+ ('virtualization', '0001_squashed_0022'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
diff --git a/netbox/extras/migrations/0060_squashed_0086.py b/netbox/extras/migrations/0060_squashed_0086.py
index 3bde7480f..2e4437c6b 100644
--- a/netbox/extras/migrations/0060_squashed_0086.py
+++ b/netbox/extras/migrations/0060_squashed_0086.py
@@ -45,13 +45,13 @@ class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_squashed_0022'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('core', '0001_initial'),
+ ('core', '0001_squashed_0005'),
('contenttypes', '0002_remove_content_type_name'),
- ('wireless', '0008_wirelesslan_status'),
- ('dcim', '0166_virtualdevicecontext'),
- ('tenancy', '0009_standardize_description_comments'),
- ('extras', '0059_exporttemplate_as_attachment'),
- ('circuits', '0041_standardize_description_comments'),
+ ('wireless', '0001_squashed_0008'),
+ ('dcim', '0160_squashed_0166'),
+ ('tenancy', '0001_squashed_0012'),
+ ('extras', '0002_squashed_0059'),
+ ('circuits', '0038_squashed_0042'),
]
operations = [
diff --git a/netbox/extras/migrations/0087_squashed_0098.py b/netbox/extras/migrations/0087_squashed_0098.py
index 839f4cbe4..21a6116b7 100644
--- a/netbox/extras/migrations/0087_squashed_0098.py
+++ b/netbox/extras/migrations/0087_squashed_0098.py
@@ -26,9 +26,9 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
- ('extras', '0086_configtemplate'),
+ ('extras', '0060_squashed_0086'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('core', '0002_managedfile'),
+ ('core', '0001_squashed_0005'),
]
operations = [
diff --git a/netbox/extras/migrations/0099_cachedvalue_ordering.py b/netbox/extras/migrations/0099_cachedvalue_ordering.py
index 36b91d59b..d3ddc5533 100644
--- a/netbox/extras/migrations/0099_cachedvalue_ordering.py
+++ b/netbox/extras/migrations/0099_cachedvalue_ordering.py
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('extras', '0098_webhook_custom_field_data_webhook_tags'),
+ ('extras', '0087_squashed_0098'),
]
operations = [
diff --git a/netbox/extras/migrations/0124_remove_staging.py b/netbox/extras/migrations/0124_remove_staging.py
new file mode 100644
index 000000000..2dab3c92e
--- /dev/null
+++ b/netbox/extras/migrations/0124_remove_staging.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.1.5 on 2025-02-20 19:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0123_journalentry_kind_default'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='stagedchange',
+ name='branch',
+ ),
+ migrations.RemoveField(
+ model_name='stagedchange',
+ name='object_type',
+ ),
+ migrations.DeleteModel(
+ name='Branch',
+ ),
+ migrations.DeleteModel(
+ name='StagedChange',
+ ),
+ ]
diff --git a/netbox/extras/migrations/0125_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0125_alter_tag_options_tag_weight.py
new file mode 100644
index 000000000..90bd055ea
--- /dev/null
+++ b/netbox/extras/migrations/0125_alter_tag_options_tag_weight.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0124_remove_staging'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ('weight', 'name')},
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='weight',
+ field=models.PositiveSmallIntegerField(default=1000),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0126_exporttemplate_file_name.py b/netbox/extras/migrations/0126_exporttemplate_file_name.py
new file mode 100644
index 000000000..980d76258
--- /dev/null
+++ b/netbox/extras/migrations/0126_exporttemplate_file_name.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0125_alter_tag_options_tag_weight'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='file_name',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py b/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py
new file mode 100644
index 000000000..1bdcb94e2
--- /dev/null
+++ b/netbox/extras/migrations/0127_configtemplate_as_attachment_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.2b1 on 2025-04-04 20:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0126_exporttemplate_file_name'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='configtemplate',
+ name='as_attachment',
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name='configtemplate',
+ name='file_extension',
+ field=models.CharField(blank=True, max_length=15),
+ ),
+ migrations.AddField(
+ model_name='configtemplate',
+ name='file_name',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='configtemplate',
+ name='mime_type',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='environment_params',
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0128_tableconfig.py b/netbox/extras/migrations/0128_tableconfig.py
new file mode 100644
index 000000000..e6d45199d
--- /dev/null
+++ b/netbox/extras/migrations/0128_tableconfig.py
@@ -0,0 +1,56 @@
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('core', '0015_remove_redundant_indexes'),
+ ('extras', '0127_configtemplate_as_attachment_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='TableConfig',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('table', models.CharField(max_length=100)),
+ ('name', models.CharField(max_length=100)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('weight', models.PositiveSmallIntegerField(default=1000)),
+ ('enabled', models.BooleanField(default=True)),
+ ('shared', models.BooleanField(default=True)),
+ (
+ 'columns',
+ django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None),
+ ),
+ (
+ 'ordering',
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(max_length=100), blank=True, null=True, size=None
+ ),
+ ),
+ (
+ 'object_type',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype'
+ ),
+ ),
+ (
+ 'user',
+ models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ options={
+ 'verbose_name': 'table config',
+ 'verbose_name_plural': 'table configs',
+ 'ordering': ('weight', 'name'),
+ },
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index e85721034..f214b1268 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -5,5 +5,4 @@ from .models import *
from .notifications import *
from .scripts import *
from .search import *
-from .staging import *
from .tags import *
diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py
index 773f5a70e..8d6b8d999 100644
--- a/netbox/extras/models/configs.py
+++ b/netbox/extras/models/configs.py
@@ -4,16 +4,13 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from jinja2.loaders import BaseLoader
-from jinja2.sandbox import SandboxedEnvironment
+from extras.models.mixins import RenderTemplateMixin
from extras.querysets import ConfigContextQuerySet
-from netbox.config import get_config
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from netbox.registry import registry
from utilities.data import deepmerge
-from utilities.jinja2 import DataFileLoader
__all__ = (
'ConfigContext',
@@ -210,7 +207,9 @@ class ConfigContextModel(models.Model):
# Config templates
#
-class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+class ConfigTemplate(
+ RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel
+):
name = models.CharField(
verbose_name=_('name'),
max_length=100
@@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
max_length=200,
blank=True
)
- template_code = models.TextField(
- verbose_name=_('template code'),
- help_text=_('Jinja2 template code.')
- )
- environment_params = models.JSONField(
- verbose_name=_('environment parameters'),
- blank=True,
- null=True,
- default=dict,
- help_text=_(
- 'Any additional parameters'
- ' to pass when constructing the Jinja2 environment.'
- )
- )
class Meta:
ordering = ('name',)
@@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
- def render(self, context=None):
- """
- Render the contents of the template.
- """
+ def get_context(self, context=None, queryset=None):
_context = dict()
-
- # Populate the default template context with NetBox model classes, namespaced by app
for app, model_names in registry['models'].items():
_context.setdefault(app, {})
for model_name in model_names:
@@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta
except LookupError:
pass
- # Add the provided context data, if any
+ # Apply the provided context data, if any
if context is not None:
_context.update(context)
- # Initialize the Jinja2 environment and instantiate the Template
- environment = self._get_environment()
- if self.data_file:
- template = environment.get_template(self.data_file.path)
- else:
- template = environment.from_string(self.template_code)
- output = template.render(**_context)
-
- # Replace CRLF-style line terminators
- return output.replace('\r\n', '\n')
-
- def _get_environment(self):
- """
- Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
- """
- # Initialize the template loader & cache the base template code (if applicable)
- if self.data_file:
- loader = DataFileLoader(data_source=self.data_source)
- loader.cache_templates({
- self.data_file.path: self.template_code
- })
- else:
- loader = BaseLoader()
-
- # Initialize the environment
- env_params = self.environment_params or {}
- environment = SandboxedEnvironment(loader=loader, **env_params)
- environment.filters.update(get_config().JINJA2_FILTERS)
-
- return environment
+ return _context
diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py
index 0950324c8..3a7273f93 100644
--- a/netbox/extras/models/mixins.py
+++ b/netbox/extras/models/mixins.py
@@ -1,11 +1,40 @@
+import importlib.abc
+import importlib.util
import os
-from importlib.machinery import SourceFileLoader
+import sys
+from django.core.files.storage import storages
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django.http import HttpResponse
+
+from extras.constants import DEFAULT_MIME_TYPE
+from extras.utils import filename_from_model, filename_from_object
+from utilities.jinja2 import render_jinja2
+
__all__ = (
'PythonModuleMixin',
+ 'RenderTemplateMixin',
)
+class CustomStoragesLoader(importlib.abc.Loader):
+ """
+ Custom loader for exec_module to use django-storages instead of the file system.
+ """
+ def __init__(self, filename):
+ self.filename = filename
+
+ def create_module(self, spec):
+ return None # Use default module creation
+
+ def exec_module(self, module):
+ storage = storages.create_storage(storages.backends["scripts"])
+ with storage.open(self.filename, 'rb') as f:
+ code = f.read()
+ exec(code, module.__dict__)
+
+
class PythonModuleMixin:
def get_jobs(self, name):
@@ -33,6 +62,99 @@ class PythonModuleMixin:
return name
def get_module(self):
- loader = SourceFileLoader(self.python_name, self.full_path)
- module = loader.load_module()
+ """
+ Load the module using importlib, but use a custom loader to use django-storages
+ instead of the file system.
+ """
+ spec = importlib.util.spec_from_file_location(self.python_name, self.name)
+ if spec is None:
+ raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
+ loader = CustomStoragesLoader(self.name)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[self.python_name] = module
+ loader.exec_module(module)
+
return module
+
+
+class RenderTemplateMixin(models.Model):
+ """
+ Enables support for rendering templates.
+ """
+ template_code = models.TextField(
+ verbose_name=_('template code'),
+ help_text=_('Jinja template code.')
+ )
+ environment_params = models.JSONField(
+ verbose_name=_('environment parameters'),
+ blank=True,
+ null=True,
+ default=dict,
+ help_text=_(
+ 'Any additional parameters to pass when constructing the Jinja environment'
+ ).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment')
+ )
+ mime_type = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name=_('MIME type'),
+ help_text=_('Defaults to {default}').format(default=DEFAULT_MIME_TYPE),
+ )
+ file_name = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text=_('Filename to give to the rendered export file')
+ )
+ file_extension = models.CharField(
+ verbose_name=_('file extension'),
+ max_length=15,
+ blank=True,
+ help_text=_('Extension to append to the rendered filename')
+ )
+ as_attachment = models.BooleanField(
+ verbose_name=_('as attachment'),
+ default=True,
+ help_text=_("Download file as attachment")
+ )
+
+ class Meta:
+ abstract = True
+
+ def get_context(self, context=None, queryset=None):
+ raise NotImplementedError(_("{class_name} must implement a get_context() method.").format(
+ class_name=self.__class__
+ ))
+
+ def render(self, context=None, queryset=None):
+ """
+ Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
+ """
+ context = self.get_context(context=context, queryset=queryset)
+ env_params = self.environment_params or {}
+ output = render_jinja2(self.template_code, context, env_params)
+
+ # Replace CRLF-style line terminators
+ output = output.replace('\r\n', '\n')
+
+ return output
+
+ def render_to_response(self, context=None, queryset=None):
+ output = self.render(context=context, queryset=queryset)
+ mime_type = self.mime_type or DEFAULT_MIME_TYPE
+
+ # Build the response
+ response = HttpResponse(output, content_type=mime_type)
+
+ if self.as_attachment:
+ extension = f'.{self.file_extension}' if self.file_extension else ''
+ if self.file_name:
+ filename = self.file_name
+ elif queryset:
+ filename = filename_from_model(queryset.model)
+ elif context:
+ filename = filename_from_object(context)
+ else:
+ filename = "output"
+ response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
+
+ return response
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index d3e443b14..9da2a8d9e 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.db import models
-from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -17,16 +16,18 @@ from extras.choices import *
from extras.conditions import ConditionSet
from extras.constants import *
from extras.utils import image_upload
+from extras.models.mixins import RenderTemplateMixin
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
- CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
+ CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
)
from utilities.html import clean_html
from utilities.jinja2 import render_jinja2
from utilities.querydict import dict_to_querydict
from utilities.querysets import RestrictedQuerySet
+from utilities.tables import get_table_for_model
__all__ = (
'Bookmark',
@@ -36,6 +37,7 @@ __all__ = (
'ImageAttachment',
'JournalEntry',
'SavedFilter',
+ 'TableConfig',
'Webhook',
)
@@ -382,7 +384,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}
-class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
+class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin):
object_types = models.ManyToManyField(
to='core.ObjectType',
related_name='export_templates',
@@ -397,32 +399,9 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=200,
blank=True
)
- template_code = models.TextField(
- help_text=_(
- "Jinja2 template code. The list of objects being exported is passed as a context variable named "
- "queryset."
- )
- )
- mime_type = models.CharField(
- max_length=50,
- blank=True,
- verbose_name=_('MIME type'),
- help_text=_('Defaults to text/plain; charset=utf-8')
- )
- file_extension = models.CharField(
- verbose_name=_('file extension'),
- max_length=15,
- blank=True,
- help_text=_('Extension to append to the rendered filename')
- )
- as_attachment = models.BooleanField(
- verbose_name=_('as attachment'),
- default=True,
- help_text=_("Download file as attachment")
- )
clone_fields = (
- 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
+ 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
)
class Meta:
@@ -455,37 +434,16 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
- def render(self, queryset):
- """
- Render the contents of the template.
- """
- context = {
- 'queryset': queryset
+ def get_context(self, context=None, queryset=None):
+ _context = {
+ 'queryset': queryset,
}
- output = render_jinja2(self.template_code, context)
- # Replace CRLF-style line terminators
- output = output.replace('\r\n', '\n')
+ # Apply the provided context data, if any
+ if context is not None:
+ _context.update(context)
- return output
-
- def render_to_response(self, queryset):
- """
- Render the template to an HTTP response, delivered as a named file attachment
- """
- output = self.render(queryset)
- mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
-
- # Build the response
- response = HttpResponse(output, content_type=mime_type)
-
- if self.as_attachment:
- basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
- extension = f'.{self.file_extension}' if self.file_extension else ''
- filename = f'netbox_{basename}{extension}'
- response['Content-Disposition'] = f'attachment; filename="{filename}"'
-
- return response
+ return _context
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
@@ -568,6 +526,121 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
return qd.urlencode()
+class TableConfig(CloningMixin, ChangeLoggedModel):
+ """
+ A saved configuration of columns and ordering which applies to a specific table.
+ """
+ object_type = models.ForeignKey(
+ to='core.ObjectType',
+ on_delete=models.CASCADE,
+ related_name='table_configs',
+ help_text=_("The table's object type"),
+ )
+ table = models.CharField(
+ verbose_name=_('table'),
+ max_length=100,
+ )
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ )
+ description = models.CharField(
+ verbose_name=_('description'),
+ max_length=200,
+ blank=True,
+ )
+ user = models.ForeignKey(
+ to=settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ blank=True,
+ null=True,
+ )
+ weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
+ default=1000,
+ )
+ enabled = models.BooleanField(
+ verbose_name=_('enabled'),
+ default=True
+ )
+ shared = models.BooleanField(
+ verbose_name=_('shared'),
+ default=True
+ )
+ columns = ArrayField(
+ base_field=models.CharField(max_length=100),
+ )
+ ordering = ArrayField(
+ base_field=models.CharField(max_length=100),
+ blank=True,
+ null=True,
+ )
+
+ clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
+
+ class Meta:
+ ordering = ('weight', 'name')
+ verbose_name = _('table config')
+ verbose_name_plural = _('table configs')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('extras:tableconfig', args=[self.pk])
+
+ @property
+ def docs_url(self):
+ return f'{settings.STATIC_URL}docs/models/extras/tableconfig/'
+
+ @property
+ def table_class(self):
+ return get_table_for_model(self.object_type.model_class(), name=self.table)
+
+ @property
+ def ordering_items(self):
+ """
+ Return a list of two-tuples indicating the column(s) by which the table is to be ordered and a boolean for each
+ column indicating whether its ordering is ascending.
+ """
+ items = []
+ for col in self.ordering or []:
+ if col.startswith('-'):
+ ascending = False
+ col = col[1:]
+ else:
+ ascending = True
+ items.append((col, ascending))
+ return items
+
+ def clean(self):
+ super().clean()
+
+ # Validate table
+ if self.table_class is None:
+ raise ValidationError({
+ 'table': _("Unknown table: {name}").format(name=self.table)
+ })
+
+ table = self.table_class([])
+
+ # Validate ordering columns
+ for name in self.ordering:
+ if name.startswith('-'):
+ name = name[1:] # Strip leading hyphen
+ if name not in table.columns:
+ raise ValidationError({
+ 'ordering': _('Unknown column: {name}').format(name=name)
+ })
+
+ # Validate selected columns
+ for name in self.columns:
+ if name not in table.columns:
+ raise ValidationError({
+ 'columns': _('Unknown column: {name}').format(name=name)
+ })
+
+
class ImageAttachment(ChangeLoggedModel):
"""
An uploaded image which is associated with an object.
diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py
deleted file mode 100644
index 68d37de7f..000000000
--- a/netbox/extras/models/staging.py
+++ /dev/null
@@ -1,150 +0,0 @@
-import logging
-import warnings
-
-from django.contrib.contenttypes.fields import GenericForeignKey
-from django.db import models, transaction
-from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
-
-from extras.choices import ChangeActionChoices
-from netbox.models import ChangeLoggedModel
-from netbox.models.features import *
-from utilities.serialization import deserialize_object
-
-__all__ = (
- 'Branch',
- 'StagedChange',
-)
-
-logger = logging.getLogger('netbox.staging')
-
-
-class Branch(ChangeLoggedModel):
- """
- A collection of related StagedChanges.
- """
- name = models.CharField(
- verbose_name=_('name'),
- max_length=100,
- unique=True
- )
- description = models.CharField(
- verbose_name=_('description'),
- max_length=200,
- blank=True
- )
- user = models.ForeignKey(
- to='users.User',
- on_delete=models.SET_NULL,
- blank=True,
- null=True
- )
-
- class Meta:
- ordering = ('name',)
- verbose_name = _('branch')
- verbose_name_plural = _('branches')
-
- def __init__(self, *args, **kwargs):
- warnings.warn(
- 'The staged changes functionality has been deprecated and will be removed in a future release.',
- DeprecationWarning
- )
- super().__init__(*args, **kwargs)
-
- def __str__(self):
- return f'{self.name} ({self.pk})'
-
- def merge(self):
- logger.info(f'Merging changes in branch {self}')
- with transaction.atomic():
- for change in self.staged_changes.all():
- change.apply()
- self.staged_changes.all().delete()
-
-
-class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
- """
- The prepared creation, modification, or deletion of an object to be applied to the active database at a
- future point.
- """
- branch = models.ForeignKey(
- to=Branch,
- on_delete=models.CASCADE,
- related_name='staged_changes'
- )
- action = models.CharField(
- verbose_name=_('action'),
- max_length=20,
- choices=ChangeActionChoices
- )
- object_type = models.ForeignKey(
- to='contenttypes.ContentType',
- on_delete=models.CASCADE,
- related_name='+'
- )
- object_id = models.PositiveBigIntegerField(
- blank=True,
- null=True
- )
- object = GenericForeignKey(
- ct_field='object_type',
- fk_field='object_id'
- )
- data = models.JSONField(
- verbose_name=_('data'),
- blank=True,
- null=True
- )
-
- class Meta:
- ordering = ('pk',)
- indexes = (
- models.Index(fields=('object_type', 'object_id')),
- )
- verbose_name = _('staged change')
- verbose_name_plural = _('staged changes')
-
- def __init__(self, *args, **kwargs):
- warnings.warn(
- 'The staged changes functionality has been deprecated and will be removed in a future release.',
- DeprecationWarning
- )
- super().__init__(*args, **kwargs)
-
- def __str__(self):
- action = self.get_action_display()
- app_label, model_name = self.object_type.natural_key()
- return f"{action} {app_label}.{model_name} ({self.object_id})"
-
- @property
- def model(self):
- return self.object_type.model_class()
-
- def apply(self):
- """
- Apply the staged create/update/delete action to the database.
- """
- if self.action == ChangeActionChoices.ACTION_CREATE:
- instance = deserialize_object(self.model, self.data, pk=self.object_id)
- logger.info(f'Creating {self.model._meta.verbose_name} {instance}')
- instance.save()
-
- if self.action == ChangeActionChoices.ACTION_UPDATE:
- instance = deserialize_object(self.model, self.data, pk=self.object_id)
- logger.info(f'Updating {self.model._meta.verbose_name} {instance}')
- instance.save()
-
- if self.action == ChangeActionChoices.ACTION_DELETE:
- instance = self.model.objects.get(pk=self.object_id)
- logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
- instance.delete()
-
- # Rebuild the MPTT tree where applicable
- if issubclass(self.model, MPTTModel):
- self.model.objects.rebuild()
-
- apply.alters_data = True
-
- def get_action_color(self):
- return ChangeActionChoices.colors.get(self.action)
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index d1e329f03..b40327265 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -9,6 +9,7 @@ from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.fields import ColorField
+from utilities.querysets import RestrictedQuerySet
__all__ = (
'Tag',
@@ -39,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True,
help_text=_("The object type(s) to which this tag can be applied.")
)
+ weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
+ default=1000,
+ )
clone_fields = (
'color', 'description', 'object_types',
)
class Meta:
- ordering = ['name']
+ ordering = ('weight', 'name')
verbose_name = _('tag')
verbose_name_plural = _('tags')
@@ -72,6 +77,7 @@ class TaggedItem(GenericTaggedItemBase):
)
_netbox_private = True
+ objects = RestrictedQuerySet.as_manager()
class Meta:
indexes = [models.Index(fields=["content_type", "object_id"])]
diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py
index 59be85734..8d6628a83 100644
--- a/netbox/extras/querysets.py
+++ b/netbox/extras/querysets.py
@@ -22,8 +22,6 @@ class ConfigContextQuerySet(RestrictedQuerySet):
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
"""
- role = obj.role
-
# Device type and location assignment is relevant only for Devices
device_type = getattr(obj, 'device_type', None)
location = getattr(obj, 'location', None)
@@ -44,13 +42,16 @@ class ConfigContextQuerySet(RestrictedQuerySet):
sitegroup = getattr(obj.site, 'group', None)
sitegroups = sitegroup.get_ancestors(include_self=True) if sitegroup else []
+ # Match against the directly assigned role as well as any parent roles.
+ device_roles = obj.role.get_ancestors(include_self=True) if obj.role else []
+
queryset = self.filter(
Q(regions__in=regions) | Q(regions=None),
Q(site_groups__in=sitegroups) | Q(site_groups=None),
Q(sites=obj.site) | Q(sites=None),
Q(locations=location) | Q(locations=None),
Q(device_types=device_type) | Q(device_types=None),
- Q(roles=role) | Q(roles=None),
+ Q(roles__in=device_roles) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
Q(cluster_types=cluster_type) | Q(cluster_types=None),
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
@@ -107,6 +108,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
Q(clusters=OuterRef('cluster')) | Q(clusters=None),
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
+ Q(sites=OuterRef('site')) | Q(sites=None),
Q(
tags__pk__in=Subquery(
TaggedItem.objects.filter(
@@ -128,9 +130,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
base_query.add(Q(locations=None), Q.AND)
base_query.add(Q(device_types=None), Q.AND)
- base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
- base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
-
+ # MPTT-based filters
base_query.add(
(Q(
regions__tree_id=OuterRef('site__region__tree_id'),
@@ -140,7 +140,6 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) | Q(regions=None)),
Q.AND
)
-
base_query.add(
(Q(
site_groups__tree_id=OuterRef('site__group__tree_id'),
@@ -150,6 +149,15 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
) | Q(site_groups=None)),
Q.AND
)
+ base_query.add(
+ (Q(
+ roles__tree_id=OuterRef('role__tree_id'),
+ roles__level__lte=OuterRef('role__level'),
+ roles__lft__lte=OuterRef('role__lft'),
+ roles__rght__gte=OuterRef('role__rght'),
+ ) | Q(roles=None)),
+ Q.AND
+ )
return base_query
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 803590cf9..f96066fb1 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -2,10 +2,12 @@ import inspect
import json
import logging
import os
+import re
import yaml
from django import forms
from django.conf import settings
+from django.core.files.storage import storages
from django.core.validators import RegexValidator
from django.utils import timezone
from django.utils.functional import classproperty
@@ -370,9 +372,46 @@ class BaseScript:
def filename(self):
return inspect.getfile(self.__class__)
+ def findsource(self, object):
+ storage = storages.create_storage(storages.backends["scripts"])
+ with storage.open(os.path.basename(self.filename), 'r') as f:
+ data = f.read()
+
+ # Break the source code into lines
+ lines = [line + '\n' for line in data.splitlines()]
+
+ # Find the class definition
+ name = object.__name__
+ pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
+ # use the class definition with the least indentation
+ candidates = []
+ for i in range(len(lines)):
+ match = pat.match(lines[i])
+ if match:
+ if lines[i][0] == 'c':
+ return lines, i
+
+ candidates.append((match.group(1), i))
+ if not candidates:
+ raise OSError('could not find class definition')
+
+ # Sort the candidates by whitespace, and by line number
+ candidates.sort()
+ return lines, candidates[0][1]
+
@property
def source(self):
- return inspect.getsource(self.__class__)
+ # Can't use inspect.getsource() as it uses os to get the file
+ # inspect uses ast, but that is overkill for this as we only do
+ # classes.
+ object = self.__class__
+
+ try:
+ lines, lnum = self.findsource(object)
+ lines = inspect.getblock(lines[lnum:])
+ return ''.join(lines)
+ except OSError:
+ return ''
@classmethod
def _get_vars(cls):
@@ -528,6 +567,11 @@ class BaseScript:
"""
Return data from a YAML file
"""
+ # TODO: DEPRECATED: Remove this method in v4.4
+ self._log(
+ _("load_yaml is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile, Loader=yaml.SafeLoader)
@@ -538,6 +582,11 @@ class BaseScript:
"""
Return data from a JSON file
"""
+ # TODO: DEPRECATED: Remove this method in v4.4
+ self._log(
+ _("load_json is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
@@ -553,7 +602,6 @@ class BaseScript:
Run the report and save its results. Each test method will be executed in order.
"""
self.logger.info("Running report")
-
try:
for test_name in self.tests:
self._current_test = test_name
diff --git a/netbox/extras/storage.py b/netbox/extras/storage.py
new file mode 100644
index 000000000..ede4fac7f
--- /dev/null
+++ b/netbox/extras/storage.py
@@ -0,0 +1,14 @@
+from django.conf import settings
+from django.core.files.storage import FileSystemStorage
+from django.utils.functional import cached_property
+
+
+class ScriptFileSystemStorage(FileSystemStorage):
+ """
+ Custom storage for scripts - for django-storages as the default one will
+ go off media-root and raise security errors as the scripts can be outside
+ the media-root directory.
+ """
+ @cached_property
+ def base_location(self):
+ return settings.SCRIPTS_ROOT
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 805fa5621..e6f488fde 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -30,6 +30,7 @@ __all__ = (
'ScriptResultsTable',
'ScriptJobTable',
'SubscriptionTable',
+ 'TableConfigTable',
'TaggedItemTable',
'TagTable',
'WebhookTable',
@@ -186,6 +187,15 @@ class ExportTemplateTable(NetBoxTable):
object_types = columns.ContentTypesColumn(
verbose_name=_('Object Types'),
)
+ mime_type = tables.Column(
+ verbose_name=_('MIME Type')
+ )
+ file_name = tables.Column(
+ verbose_name=_('File Name'),
+ )
+ file_extension = tables.Column(
+ verbose_name=_('File Extension'),
+ )
as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'),
false_mark=None
@@ -206,11 +216,12 @@ class ExportTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ExportTemplate
fields = (
- 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
- 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
+ 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'is_synced',
)
@@ -274,6 +285,36 @@ class SavedFilterTable(NetBoxTable):
)
+class TableConfigTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ object_type = columns.ContentTypeColumn(
+ verbose_name=_('Object Type'),
+ )
+ table = tables.Column(
+ verbose_name=_('Table Name')
+ )
+ enabled = columns.BooleanColumn(
+ verbose_name=_('Enabled'),
+ )
+ shared = columns.BooleanColumn(
+ verbose_name=_('Shared'),
+ false_mark=None
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = TableConfig
+ fields = (
+ 'pk', 'id', 'name', 'object_type', 'table', 'description', 'user', 'weight', 'enabled', 'shared', 'created',
+ 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'object_type', 'table', 'user', 'description', 'enabled', 'shared',
+ )
+
+
class BookmarkTable(NetBoxTable):
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Types'),
@@ -452,8 +493,8 @@ class TagTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Tag
fields = (
- 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
@@ -532,6 +573,19 @@ class ConfigTemplateTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
+ mime_type = tables.Column(
+ verbose_name=_('MIME Type')
+ )
+ file_name = tables.Column(
+ verbose_name=_('File Name'),
+ )
+ file_extension = tables.Column(
+ verbose_name=_('File Extension'),
+ )
+ as_attachment = columns.BooleanColumn(
+ verbose_name=_('As Attachment'),
+ false_mark=None
+ )
tags = columns.TagColumn(
url_name='extras:configtemplate_list'
)
@@ -559,8 +613,9 @@ class ConfigTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ConfigTemplate
fields = (
- 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
- 'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
+ 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
+ 'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
+ 'vm_count', 'created', 'last_updated', 'tags',
)
default_columns = (
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 63baf44d3..6e3fb37fc 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'],
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ 'file_name': 'test_export_template_6',
},
]
bulk_update_data = {
@@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
ExportTemplate(
name='Export Template 2',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ file_name='export_template_2',
+ file_extension='test',
),
ExportTemplate(
name='Export Template 3',
@@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
)
ExportTemplate.objects.bulk_create(export_templates)
+
+ device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates:
- et.object_types.set([ObjectType.objects.get_for_model(Device)])
+ et.object_types.set([device_object_type])
class TagTest(APIViewTestCases.APIViewTestCase):
@@ -513,6 +518,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Tag 4',
'slug': 'tag-4',
+ 'weight': 1000,
},
{
'name': 'Tag 5',
@@ -527,6 +533,24 @@ class TagTest(APIViewTestCases.APIViewTestCase):
'description': 'New description',
}
+ @classmethod
+ def setUpTestData(cls):
+
+ tags = (
+ Tag(name='Tag 1', slug='tag-1'),
+ Tag(name='Tag 2', slug='tag-2'),
+ Tag(name='Tag 3', slug='tag-3', weight=26),
+ )
+ Tag.objects.bulk_create(tags)
+
+
+class TaggedItemTest(
+ APIViewTestCases.GetObjectViewTestCase,
+ APIViewTestCases.ListObjectsViewTestCase
+):
+ model = TaggedItem
+ brief_fields = ['display', 'id', 'object', 'object_id', 'object_type', 'tag', 'url']
+
@classmethod
def setUpTestData(cls):
@@ -537,6 +561,16 @@ class TagTest(APIViewTestCases.APIViewTestCase):
)
Tag.objects.bulk_create(tags)
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+ sites[0].tags.set([tags[0], tags[1]])
+ sites[1].tags.set([tags[1], tags[2]])
+ sites[2].tags.set([tags[2], tags[0]])
+
# TODO: Standardize to APIViewTestCase (needs create & update tests)
class ImageAttachmentTest(
@@ -721,6 +755,10 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Config Template 4',
'template_code': 'Foo: {{ foo }}',
+ 'mime_type': 'text/plain',
+ 'file_name': 'output4',
+ 'file_extension': 'txt',
+ 'as_attachment': True,
},
{
'name': 'Config Template 5',
@@ -744,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
),
ConfigTemplate(
name='Config Template 2',
- template_code='Bar: {{ bar }}'
+ template_code='Bar: {{ bar }}',
),
ConfigTemplate(
name='Config Template 3',
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index 03d8508af..f9147a30c 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -616,16 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet
- ignore_fields = ('template_code', 'data_path')
+ ignore_fields = ('template_code', 'environment_params', 'data_path')
@classmethod
def setUpTestData(cls):
object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = (
- ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
- ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'),
- ExportTemplate(name='Export Template 3', template_code='TESTING'),
+ ExportTemplate(
+ name='Export Template 1',
+ template_code='TESTING',
+ description='foobar1',
+ mime_type='text/foo',
+ file_name='foo',
+ file_extension='foo',
+ as_attachment=True,
+ ),
+ ExportTemplate(
+ name='Export Template 2',
+ template_code='TESTING',
+ description='foobar2',
+ mime_type='text/bar',
+ file_name='bar',
+ file_extension='bar',
+ as_attachment=True,
+ ),
+ ExportTemplate(
+ name='Export Template 3',
+ template_code='TESTING',
+ mime_type='text/baz',
+ file_name='baz',
+ file_extension='baz',
+ as_attachment=False,
+ ),
)
ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates):
@@ -649,6 +672,22 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_mime_type(self):
+ params = {'mime_type': ['text/foo', 'text/bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_name(self):
+ params = {'file_name': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_extension(self):
+ params = {'file_extension': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_as_attachment(self):
+ params = {'as_attachment': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all()
@@ -884,7 +923,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(device_roles)
+ for device_role in device_roles:
+ device_role.save()
platforms = (
Platform(name='Platform 1', slug='platform-1'),
@@ -1067,9 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
config_templates = (
- ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
- ConfigTemplate(name='Config Template 2', template_code='TESTING', description='foobar2'),
- ConfigTemplate(name='Config Template 3', template_code='TESTING'),
+ ConfigTemplate(
+ name='Config Template 1',
+ template_code='TESTING',
+ description='foobar1',
+ mime_type='text/foo',
+ file_name='foo',
+ file_extension='foo',
+ as_attachment=True,
+ ),
+ ConfigTemplate(
+ name='Config Template 2',
+ template_code='TESTING',
+ description='foobar2',
+ mime_type='text/bar',
+ file_name='bar',
+ file_extension='bar',
+ as_attachment=True,
+ ),
+ ConfigTemplate(
+ name='Config Template 3',
+ template_code='TESTING',
+ mime_type='text/baz',
+ file_name='baz',
+ file_extension='baz',
+ as_attachment=False,
+ ),
)
ConfigTemplate.objects.bulk_create(config_templates)
@@ -1085,6 +1148,22 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_mime_type(self):
+ params = {'mime_type': ['text/foo', 'text/bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_name(self):
+ params = {'file_name': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_extension(self):
+ params = {'file_extension': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_as_attachment(self):
+ params = {'as_attachment': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all()
@@ -1141,6 +1220,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'module',
'modulebay',
'moduletype',
+ 'moduletypeprofile',
'platform',
'powerfeed',
'poweroutlet',
@@ -1195,9 +1275,9 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
}
tags = (
- Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
- Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
- Tag(name='Tag 3', slug='tag-3', color='0000ff'),
+ Tag(name='Tag 1', slug='tag-1', color='ff0000', weight=1000, description='foobar1'),
+ Tag(name='Tag 2', slug='tag-2', color='00ff00', weight=2000, description='foobar2'),
+ Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=3000),
)
Tag.objects.bulk_create(tags)
tags[0].object_types.add(object_types['site'])
@@ -1250,6 +1330,66 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
['Tag 2', 'Tag 3']
)
+ def test_weight(self):
+ params = {'weight': [1000, 2000]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class TaggedItemFilterSetTestCase(TestCase):
+ queryset = TaggedItem.objects.all()
+ filterset = TaggedItemFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ tags = (
+ Tag(name='Tag 1', slug='tag-1'),
+ Tag(name='Tag 2', slug='tag-2'),
+ Tag(name='Tag 3', slug='tag-3'),
+ )
+ Tag.objects.bulk_create(tags)
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+ sites[0].tags.add(tags[0])
+ sites[1].tags.add(tags[1])
+ sites[2].tags.add(tags[2])
+
+ tenants = (
+ Tenant(name='Tenant 1', slug='tenant-1'),
+ Tenant(name='Tenant 2', slug='tenant-2'),
+ Tenant(name='Tenant 3', slug='tenant-3'),
+ )
+ Tenant.objects.bulk_create(tenants)
+ tenants[0].tags.add(tags[0])
+ tenants[1].tags.add(tags[1])
+ tenants[2].tags.add(tags[2])
+
+ def test_tag(self):
+ tags = Tag.objects.all()[:2]
+ params = {'tag': [tags[0].slug, tags[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'tag_id': [tags[0].pk, tags[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_object_type(self):
+ object_type = ObjectType.objects.get_for_model(Site)
+ params = {'object_type': 'dcim.site'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'object_type_id': [object_type.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_object_id(self):
+ site_ids = Site.objects.values_list('pk', flat=True)
+ params = {
+ 'object_type': 'dcim.site',
+ 'object_id': site_ids[:2],
+ }
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ChangeLoggedFilterSetTestCase(TestCase):
"""
diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py
index 34882537d..089e47c02 100644
--- a/netbox/extras/tests/test_models.py
+++ b/netbox/extras/tests/test_models.py
@@ -11,6 +11,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class TagTest(TestCase):
+ def test_default_ordering_weight_then_name_is_set(self):
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000)
+ Tag.objects.create(name='Tag 2', slug='tag-2') # Default: 1000
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000)
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000)
+
+ tags = Tag.objects.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
+ def test_tag_related_manager_ordering_weight_then_name(self):
+ tags = [
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000),
+ Tag.objects.create(name='Tag 2', slug='tag-2'), # Default: 1000
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=2000),
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=2000),
+ ]
+
+ site = Site.objects.create(name='Site 1')
+ for tag in tags:
+ site.tags.add(tag)
+ site.save()
+
+ site = Site.objects.first()
+ tags = site.tags.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣')
tag.save()
diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py
new file mode 100644
index 000000000..b851acab8
--- /dev/null
+++ b/netbox/extras/tests/test_utils.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+
+from extras.models import ExportTemplate
+from extras.utils import filename_from_model
+from tenancy.models import ContactGroup, TenantGroup
+from wireless.models import WirelessLANGroup
+
+
+class FilenameFromModelTests(TestCase):
+ def test_expected_output(self):
+ cases = (
+ (ExportTemplate, 'netbox_export_templates'),
+ (ContactGroup, 'netbox_contact_groups'),
+ (TenantGroup, 'netbox_tenant_groups'),
+ (WirelessLANGroup, 'netbox_wireless_lan_groups'),
+ )
+
+ for model, expected in cases:
+ self.assertEqual(filename_from_model(model), expected)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 5d82fae4c..6378b29b8 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -301,11 +301,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_type = ObjectType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
+ ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
export_templates = (
ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
- ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
- ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE),
+ ExportTemplate(
+ name='Export Template 2', template_code=TEMPLATE_CODE, environment_params={"trim_blocks": True}
+ ),
+ ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
)
ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
@@ -315,13 +318,15 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X',
'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE,
+ 'environment_params': ENVIRONMENT_PARAMS,
+ 'file_name': 'template_x',
}
cls.csv_data = (
- "name,object_types,template_code",
- f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
+ "name,object_types,template_code,file_name",
+ f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
+ f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
+ f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
)
cls.csv_update_data = (
@@ -441,8 +446,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tags = (
Tag(name='Tag 1', slug='tag-1'),
- Tag(name='Tag 2', slug='tag-2'),
- Tag(name='Tag 3', slug='tag-3'),
+ Tag(name='Tag 2', slug='tag-2', weight=1),
+ Tag(name='Tag 3', slug='tag-3', weight=32767),
)
Tag.objects.bulk_create(tags)
@@ -451,13 +456,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tag-x',
'color': 'c0c0c0',
'comments': 'Some comments',
+ 'weight': 11,
}
cls.csv_data = (
- "name,slug,color,description",
- "Tag 4,tag-4,ff0000,Fourth tag",
- "Tag 5,tag-5,00ff00,Fifth tag",
- "Tag 6,tag-6,0000ff,Sixth tag",
+ "name,slug,color,description,weight",
+ "Tag 4,tag-4,ff0000,Fourth tag,0",
+ "Tag 5,tag-5,00ff00,Fifth tag,1111",
+ "Tag 6,tag-6,0000ff,Sixth tag,0",
)
cls.csv_update_data = (
@@ -535,11 +541,23 @@ class ConfigTemplateTestCase(
@classmethod
def setUpTestData(cls):
TEMPLATE_CODE = """Foo: {{ foo }}"""
+ ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
config_templates = (
- ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
- ConfigTemplate(name='Config Template 2', template_code=TEMPLATE_CODE),
- ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE),
+ ConfigTemplate(
+ name='Config Template 1',
+ template_code=TEMPLATE_CODE)
+ ,
+ ConfigTemplate(
+ name='Config Template 2',
+ template_code=TEMPLATE_CODE,
+ environment_params={"trim_blocks": True},
+ ),
+ ConfigTemplate(
+ name='Config Template 3',
+ template_code=TEMPLATE_CODE,
+ file_name='config_template_3',
+ ),
)
ConfigTemplate.objects.bulk_create(config_templates)
@@ -547,6 +565,8 @@ class ConfigTemplateTestCase(
'name': 'Config Template X',
'description': 'Config template',
'template_code': TEMPLATE_CODE,
+ 'environment_params': ENVIRONMENT_PARAMS,
+ 'file_name': 'config_x',
}
cls.csv_update_data = (
@@ -558,6 +578,10 @@ class ConfigTemplateTestCase(
cls.bulk_edit_data = {
'description': 'New description',
+ 'mime_type': 'text/html',
+ 'file_name': 'output',
+ 'file_extension': 'html',
+ 'as_attachment': True,
}
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index f470f72b1..ca07ba903 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -19,6 +19,9 @@ urlpatterns = [
path('export-templates/', include(get_model_urls('extras', 'exporttemplate', detail=False))),
path('export-templates//', include(get_model_urls('extras', 'exporttemplate'))),
+ path('table-configs/', include(get_model_urls('extras', 'tableconfig', detail=False))),
+ path('table-configs//', include(get_model_urls('extras', 'tableconfig'))),
+
path('saved-filters/', include(get_model_urls('extras', 'savedfilter', detail=False))),
path('saved-filters//', include(get_model_urls('extras', 'savedfilter'))),
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index efe7ada5b..c9f554d22 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -1,12 +1,15 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
+from django.db import models
+from django.db.models import Q
from taggit.managers import _TaggableManager
from netbox.context import current_request
from .validators import CustomValidator
__all__ = (
+ 'SharedObjectViewMixin',
'image_upload',
'is_report',
'is_script',
@@ -15,6 +18,39 @@ __all__ = (
)
+class SharedObjectViewMixin:
+
+ def get_queryset(self, request):
+ """
+ Return only shared objects, or those owned by the current user, unless this is a superuser.
+ """
+ queryset = super().get_queryset(request)
+ if request.user.is_superuser:
+ return queryset
+ if request.user.is_anonymous:
+ return queryset.filter(shared=True)
+ return queryset.filter(
+ Q(shared=True) | Q(user=request.user)
+ )
+
+
+def filename_from_model(model: models.Model) -> str:
+ """Standardises how we generate filenames from model class for exports"""
+ base = model._meta.verbose_name_plural.lower().replace(' ', '_')
+ return f'netbox_{base}'
+
+
+def filename_from_object(context: dict) -> str:
+ """Standardises how we generate filenames from model class for exports"""
+ if 'device' in context:
+ base = f"{context['device'].name or 'config'}"
+ elif 'virtualmachine' in context:
+ base = f"{context['virtualmachine'].name or 'config'}"
+ else:
+ base = 'config'
+ return base
+
+
def is_taggable(obj):
"""
Return True if the instance can have Tags assigned to it; False otherwise.
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 72ea72574..ae9337779 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -13,12 +13,12 @@ from django.views.generic import View
from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices
-from core.forms import ManagedFileForm
from core.models import Job
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
+from extras.utils import SharedObjectViewMixin
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from netbox.views.generic.mixins import TableMixin
@@ -286,39 +286,22 @@ class ExportTemplateBulkSyncDataView(generic.BulkSyncDataView):
# Saved filters
#
-class SavedFilterMixin:
-
- def get_queryset(self, request):
- """
- Return only shared SavedFilters, or those owned by the current user, unless
- this is a superuser.
- """
- queryset = SavedFilter.objects.all()
- user = request.user
- if user.is_superuser:
- return queryset
- if user.is_anonymous:
- return queryset.filter(shared=True)
- return queryset.filter(
- Q(shared=True) | Q(user=user)
- )
-
-
@register_model_view(SavedFilter, 'list', path='', detail=False)
-class SavedFilterListView(SavedFilterMixin, generic.ObjectListView):
+class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
+ queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
filterset_form = forms.SavedFilterFilterForm
table = tables.SavedFilterTable
@register_model_view(SavedFilter)
-class SavedFilterView(SavedFilterMixin, generic.ObjectView):
+class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'add', detail=False)
@register_model_view(SavedFilter, 'edit')
-class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
+class SavedFilterEditView(SharedObjectViewMixin, generic.ObjectEditView):
queryset = SavedFilter.objects.all()
form = forms.SavedFilterForm
@@ -329,18 +312,18 @@ class SavedFilterEditView(SavedFilterMixin, generic.ObjectEditView):
@register_model_view(SavedFilter, 'delete')
-class SavedFilterDeleteView(SavedFilterMixin, generic.ObjectDeleteView):
+class SavedFilterDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_import', path='import', detail=False)
-class SavedFilterBulkImportView(SavedFilterMixin, generic.BulkImportView):
+class SavedFilterBulkImportView(SharedObjectViewMixin, generic.BulkImportView):
queryset = SavedFilter.objects.all()
model_form = forms.SavedFilterImportForm
@register_model_view(SavedFilter, 'bulk_edit', path='edit', detail=False)
-class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
+class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
@@ -348,12 +331,71 @@ class SavedFilterBulkEditView(SavedFilterMixin, generic.BulkEditView):
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
-class SavedFilterBulkDeleteView(SavedFilterMixin, generic.BulkDeleteView):
+class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all()
filterset = filtersets.SavedFilterFilterSet
table = tables.SavedFilterTable
+#
+# Table configs
+#
+
+@register_model_view(TableConfig, 'list', path='', detail=False)
+class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
+ queryset = TableConfig.objects.all()
+ filterset = filtersets.TableConfigFilterSet
+ filterset_form = forms.TableConfigFilterForm
+ table = tables.TableConfigTable
+ actions = {
+ 'export': {'view'},
+ }
+
+
+@register_model_view(TableConfig)
+class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
+ queryset = TableConfig.objects.all()
+
+ def get_extra_context(self, request, instance):
+ table = instance.table_class([])
+ return {
+ 'columns': dict(table.columns.items()),
+ }
+
+
+@register_model_view(TableConfig, 'add', detail=False)
+@register_model_view(TableConfig, 'edit')
+class TableConfigEditView(SharedObjectViewMixin, generic.ObjectEditView):
+ queryset = TableConfig.objects.all()
+ form = forms.TableConfigForm
+ template_name = 'extras/tableconfig_edit.html'
+
+ def alter_object(self, obj, request, url_args, url_kwargs):
+ if not obj.pk:
+ obj.user = request.user
+ return obj
+
+
+@register_model_view(TableConfig, 'delete')
+class TableConfigDeleteView(SharedObjectViewMixin, generic.ObjectDeleteView):
+ queryset = TableConfig.objects.all()
+
+
+@register_model_view(TableConfig, 'bulk_edit', path='edit', detail=False)
+class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
+ queryset = TableConfig.objects.all()
+ filterset = filtersets.TableConfigFilterSet
+ table = tables.TableConfigTable
+ form = forms.TableConfigBulkEditForm
+
+
+@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
+class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
+ queryset = TableConfig.objects.all()
+ filterset = filtersets.TableConfigFilterSet
+ table = tables.TableConfigTable
+
+
#
# Bookmarks
#
@@ -1218,7 +1260,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
@register_model_view(ScriptModule, 'edit')
class ScriptModuleCreateView(generic.ObjectEditView):
queryset = ScriptModule.objects.all()
- form = ManagedFileForm
+ form = forms.ScriptFileForm
def alter_object(self, obj, *args, **kwargs):
obj.file_root = ManagedFileRootPathChoices.SCRIPTS
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
index 889c97ac2..368075217 100644
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -3,10 +3,10 @@ import hmac
import logging
import requests
-from django.conf import settings
from django_rq import job
from jinja2.exceptions import TemplateError
+from utilities.proxy import resolve_proxies
from .constants import WEBHOOK_EVENT_TYPES
logger = logging.getLogger('netbox.webhooks')
@@ -63,9 +63,10 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
raise e
# Prepare the HTTP request
+ url = webhook.render_payload_url(context)
params = {
'method': webhook.http_method,
- 'url': webhook.render_payload_url(context),
+ 'url': url,
'headers': headers,
'data': body.encode('utf8'),
}
@@ -88,7 +89,8 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
session.verify = webhook.ssl_verification
if webhook.ca_file_path:
session.verify = webhook.ca_file_path
- response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
+ proxies = resolve_proxies(url=url, context={'client': webhook})
+ response = session.send(prepared_request, proxies=proxies)
if 200 <= response.status_code <= 299:
logger.info(f"Request succeeded; response status {response.status_code}")
diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py
index bfc7ac546..6f815b5ce 100644
--- a/netbox/ipam/api/serializers_/ip.py
+++ b/netbox/ipam/api/serializers_/ip.py
@@ -147,7 +147,8 @@ class IPRangeSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
- 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py
index 61b330d01..c7c1bb136 100644
--- a/netbox/ipam/api/serializers_/services.py
+++ b/netbox/ipam/api/serializers_/services.py
@@ -1,9 +1,13 @@
-from dcim.api.serializers_.devices import DeviceSerializer
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
from ipam.choices import *
+from ipam.constants import SERVICE_ASSIGNMENT_MODELS
from ipam.models import IPAddress, Service, ServiceTemplate
-from netbox.api.fields import ChoiceField, SerializedPKRelatedField
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
-from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer
+from utilities.api import get_serializer_for_model
from .ip import IPAddressSerializer
__all__ = (
@@ -25,8 +29,6 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
class ServiceSerializer(NetBoxModelSerializer):
- device = DeviceSerializer(nested=True, required=False, allow_null=True)
- virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
@@ -35,11 +37,24 @@ class ServiceSerializer(NetBoxModelSerializer):
required=False,
many=True
)
+ parent_object_type = ContentTypeField(
+ queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS)
+ )
+ parent = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Service
fields = [
- 'id', 'url', 'display_url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports',
- 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'parent_object_type', 'parent_object_id', 'parent', 'name',
+ 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields',
+ 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_parent(self, obj):
+ if obj.parent is None:
+ return None
+ serializer = get_serializer_for_model(obj.parent)
+ context = {'request': self.context['request']}
+ return serializer(obj.parent, nested=True, context=context).data
diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py
index 9b5501dc5..a6f428343 100644
--- a/netbox/ipam/api/serializers_/vlans.py
+++ b/netbox/ipam/api/serializers_/vlans.py
@@ -37,6 +37,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
scope = serializers.SerializerMethodField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True)
+ tenant = TenantSerializer(nested=True, required=False, allow_null=True)
# Related object counts
vlan_count = RelatedObjectCountField('vlans')
@@ -45,7 +46,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
model = VLANGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
- 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
+ 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = []
diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py
index 6dffd3287..947f1adea 100644
--- a/netbox/ipam/constants.py
+++ b/netbox/ipam/constants.py
@@ -83,6 +83,12 @@ VLANGROUP_SCOPE_TYPES = (
# Services
#
+SERVICE_ASSIGNMENT_MODELS = Q(
+ Q(app_label='dcim', model='device') |
+ Q(app_label='ipam', model='fhrpgroup') |
+ Q(app_label='virtualization', model='virtualmachine')
+)
+
# 16-bit port number
SERVICE_PORT_MIN = 1
SERVICE_PORT_MAX = 65535
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 63131b125..087bcc3b0 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -504,7 +504,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilte
class Meta:
model = IPRange
- fields = ('id', 'mark_utilized', 'size', 'description')
+ fields = ('id', 'mark_populated', 'mark_utilized', 'size', 'description')
def search(self, queryset, name, value):
if not value.strip():
@@ -883,7 +883,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
)
-class VLANGroupFilterSet(OrganizationalModelFilterSet):
+class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_type = ContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'
@@ -1163,25 +1163,35 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
- device_id = django_filters.ModelMultipleChoiceFilter(
- queryset=Device.objects.all(),
- label=_('Device (ID)'),
- )
- device = django_filters.ModelMultipleChoiceFilter(
- field_name='device__name',
- queryset=Device.objects.all(),
- to_field_name='name',
+ device = MultiValueCharFilter(
+ method='filter_device',
+ field_name='name',
label=_('Device (name)'),
)
- virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
- queryset=VirtualMachine.objects.all(),
+ device_id = MultiValueNumberFilter(
+ method='filter_device',
+ field_name='pk',
+ label=_('Device (ID)'),
+ )
+ virtual_machine = MultiValueCharFilter(
+ method='filter_virtual_machine',
+ field_name='name',
+ label=_('Virtual machine (name)'),
+ )
+ virtual_machine_id = MultiValueNumberFilter(
+ method='filter_virtual_machine',
+ field_name='pk',
label=_('Virtual machine (ID)'),
)
- virtual_machine = django_filters.ModelMultipleChoiceFilter(
- field_name='virtual_machine__name',
- queryset=VirtualMachine.objects.all(),
- to_field_name='name',
- label=_('Virtual machine (name)'),
+ fhrpgroup = MultiValueCharFilter(
+ method='filter_fhrp_group',
+ field_name='name',
+ label=_('FHRP Group (name)'),
+ )
+ fhrpgroup_id = MultiValueNumberFilter(
+ method='filter_fhrp_group',
+ field_name='pk',
+ label=_('FHRP Group (ID)'),
)
ip_address_id = django_filters.ModelMultipleChoiceFilter(
field_name='ipaddresses',
@@ -1201,7 +1211,7 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
class Meta:
model = Service
- fields = ('id', 'name', 'protocol', 'description')
+ fields = ('id', 'name', 'protocol', 'description', 'parent_object_type', 'parent_object_id')
def search(self, queryset, name, value):
if not value.strip():
@@ -1209,6 +1219,33 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter)
+ def filter_device(self, queryset, name, value):
+ devices = Device.objects.filter(**{'{}__in'.format(name): value})
+ if not devices.exists():
+ return queryset.none()
+ service_ids = []
+ for device in devices:
+ service_ids.extend(device.services.values_list('id', flat=True))
+ return queryset.filter(id__in=service_ids)
+
+ def filter_fhrp_group(self, queryset, name, value):
+ groups = FHRPGroup.objects.filter(**{'{}__in'.format(name): value})
+ if not groups.exists():
+ return queryset.none()
+ service_ids = []
+ for group in groups:
+ service_ids.extend(group.services.values_list('id', flat=True))
+ return queryset.filter(id__in=service_ids)
+
+ def filter_virtual_machine(self, queryset, name, value):
+ virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
+ if not virtual_machines.exists():
+ return queryset.none()
+ service_ids = []
+ for vm in virtual_machines:
+ service_ids.extend(vm.services.values_list('id', flat=True))
+ return queryset.filter(id__in=service_ids)
+
class PrimaryIPFilterSet(django_filters.FilterSet):
"""
diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py
index 7f3216cfd..864630bd4 100644
--- a/netbox/ipam/forms/bulk_edit.py
+++ b/netbox/ipam/forms/bulk_edit.py
@@ -296,6 +296,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
queryset=Role.objects.all(),
required=False
)
+ mark_populated = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect(),
+ label=_('Treat as populated')
+ )
mark_utilized = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
@@ -430,11 +435,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
label=_('VLAN ID ranges'),
required=False
)
+ tenant = DynamicModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False
+ )
model = VLANGroup
fieldsets = (
FieldSet('site', 'vid_ranges', 'description'),
FieldSet('scope_type', 'scope', name=_('Scope')),
+ FieldSet('tenant', name=_('Tenancy')),
)
nullable_fields = ('description', 'scope')
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index 0fbcd414b..d17944674 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -275,8 +275,8 @@ class IPRangeImportForm(NetBoxModelImportForm):
class Meta:
model = IPRange
fields = (
- 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_utilized', 'description',
- 'comments', 'tags',
+ 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_populated', 'mark_utilized',
+ 'description', 'comments', 'tags',
)
@@ -454,10 +454,17 @@ class VLANGroupImportForm(NetBoxModelImportForm):
vid_ranges = NumericRangeArrayField(
required=False
)
+ tenant = CSVModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Assigned tenant')
+ )
class Meta:
model = VLANGroup
- fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
+ fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'tags')
labels = {
'scope_id': 'Scope ID',
}
@@ -552,19 +559,21 @@ class ServiceTemplateImportForm(NetBoxModelImportForm):
class ServiceImportForm(NetBoxModelImportForm):
- device = CSVModelChoiceField(
- label=_('Device'),
+ parent_object_type = CSVContentTypeField(
+ queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
+ required=True,
+ label=_('Parent type (app & model)')
+ )
+ parent = CSVModelChoiceField(
+ label=_('Parent'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
- help_text=_('Required if not assigned to a VM')
+ help_text=_('Parent object name')
)
- virtual_machine = CSVModelChoiceField(
- label=_('Virtual machine'),
- queryset=VirtualMachine.objects.all(),
+ parent_object_id = forms.IntegerField(
required=False,
- to_field_name='name',
- help_text=_('Required if not assigned to a device')
+ help_text=_('Parent object ID'),
)
protocol = CSVChoiceField(
label=_('Protocol'),
@@ -581,15 +590,52 @@ class ServiceImportForm(NetBoxModelImportForm):
class Meta:
model = Service
fields = (
- 'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
+ 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
)
- def clean_ipaddresses(self):
- parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
- for ip_address in self.cleaned_data['ipaddresses']:
- if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ # Limit parent queryset by assigned parent object type
+ if data:
+ match data.get('parent_object_type'):
+ case 'dcim.device':
+ self.fields['parent'].queryset = Device.objects.all()
+ case 'ipam.fhrpgroup':
+ self.fields['parent'].queryset = FHRPGroup.objects.all()
+ case 'virtualization.virtualmachine':
+ self.fields['parent'].queryset = VirtualMachine.objects.all()
+
+ def save(self, *args, **kwargs):
+ if (parent := self.cleaned_data.get('parent')):
+ self.instance.parent = parent
+
+ return super().save(*args, **kwargs)
+
+ def clean(self):
+ super().clean()
+
+ if (parent_ct := self.cleaned_data.get('parent_object_type')):
+ if (parent := self.cleaned_data.get('parent')):
+ self.cleaned_data['parent_object_id'] = parent.pk
+ elif (parent_id := self.cleaned_data.get('parent_object_id')):
+ parent = parent_ct.model_class().objects.filter(id=parent_id).first()
+ self.cleaned_data['parent'] = parent
+ else:
+ # If a parent object type is passed and we've made it here, then raise a validation
+ # error since an associated parent object or parent object id has not been passed
raise forms.ValidationError(
- _("{ip} is not assigned to this device/VM.").format(ip=ip_address)
+ _("One of parent or parent_object_id must be included with parent_object_type")
)
- return self.cleaned_data['ipaddresses']
+ # making sure parent is defined. In cases where an import is resulting in an update, the
+ # import data might not include the parent object and so the above logic might not be
+ # triggered
+ parent = self.cleaned_data.get('parent')
+ for ip_address in self.cleaned_data.get('ipaddresses', []):
+ if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
+ raise forms.ValidationError(
+ _("{ip} is not assigned to this parent.").format(ip=ip_address)
+ )
+
+ return self.cleaned_data
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 4cfe9f872..dcd9ab5e2 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -278,7 +278,7 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFi
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
+ FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
@@ -304,6 +304,13 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFi
null_option='None',
label=_('Role')
)
+ mark_populated = forms.NullBooleanField(
+ required=False,
+ label=_('Treat as populated'),
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Treat as fully utilized'),
@@ -425,12 +432,13 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class VLANGroupFilterForm(NetBoxModelFilterSetForm):
+class VLANGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
+ FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@@ -609,7 +617,7 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')),
- FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
+ FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
device_id = DynamicModelMultipleChoiceField(
@@ -622,4 +630,9 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
required=False,
label=_('Virtual Machine'),
)
+ fhrpgroup_id = DynamicModelMultipleChoiceField(
+ queryset=FHRPGroup.objects.all(),
+ required=False,
+ label=_('FHRP Group'),
+ )
tag = TagFilterField(model)
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index 98a49524f..66674a749 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -21,7 +21,7 @@ from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, T
from utilities.forms.utils import get_field_value
from utilities.forms.widgets import DatePicker, HTMXSelect
from utilities.templatetags.builtins.filters import bettertitle
-from virtualization.models import VirtualMachine, VMInterface
+from virtualization.models import VMInterface
__all__ = (
'AggregateForm',
@@ -265,8 +265,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
fieldsets = (
FieldSet(
- 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags',
- name=_('IP Range')
+ 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
+ 'tags', name=_('IP Range')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@@ -274,8 +274,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPRange
fields = [
- 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_utilized',
- 'description', 'comments', 'tags',
+ 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
+ 'mark_utilized', 'description', 'comments', 'tags',
]
@@ -605,7 +605,7 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
return group
-class VLANGroupForm(NetBoxModelForm):
+class VLANGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
@@ -628,12 +628,13 @@ class VLANGroupForm(NetBoxModelForm):
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet('scope_type', 'scope', name=_('Scope')),
+ FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = VLANGroup
fields = [
- 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tags',
+ 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tenant_group', 'tenant', 'tags',
]
def __init__(self, *args, **kwargs):
@@ -757,16 +758,17 @@ class ServiceTemplateForm(NetBoxModelForm):
class ServiceForm(NetBoxModelForm):
- device = DynamicModelChoiceField(
- label=_('Device'),
- queryset=Device.objects.all(),
- required=False,
- selector=True
+ parent_object_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
+ widget=HTMXSelect(),
+ required=True,
+ label=_('Parent type')
)
- virtual_machine = DynamicModelChoiceField(
- label=_('Virtual machine'),
- queryset=VirtualMachine.objects.all(),
- required=False,
+ parent = DynamicModelChoiceField(
+ label=_('Parent'),
+ queryset=Device.objects.none(), # Initial queryset
+ required=True,
+ disabled=True,
selector=True
)
ports = NumericArrayField(
@@ -790,11 +792,7 @@ class ServiceForm(NetBoxModelForm):
fieldsets = (
FieldSet(
- TabbedGroups(
- FieldSet('device', name=_('Device')),
- FieldSet('virtual_machine', name=_('Virtual Machine')),
- ),
- 'name',
+ 'parent_object_type', 'parent', 'name',
InlineFields('protocol', 'ports', label=_('Port(s)')),
'ipaddresses', 'description', 'tags', name=_('Service')
),
@@ -803,9 +801,38 @@ class ServiceForm(NetBoxModelForm):
class Meta:
model = Service
fields = [
- 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
+ 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
+ 'parent_object_type',
]
+ def __init__(self, *args, **kwargs):
+ initial = kwargs.get('initial', {}).copy()
+
+ if (instance := kwargs.get('instance', None)) and instance.parent:
+ initial['parent'] = instance.parent
+
+ kwargs['initial'] = initial
+
+ super().__init__(*args, **kwargs)
+
+ if (parent_object_type_id := get_field_value(self, 'parent_object_type')):
+ try:
+ parent_type = ContentType.objects.get(pk=parent_object_type_id)
+ model = parent_type.model_class()
+ self.fields['parent'].queryset = model.objects.all()
+ self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
+ self.fields['parent'].disabled = False
+ self.fields['parent'].label = _(bettertitle(model._meta.verbose_name))
+ except ObjectDoesNotExist:
+ pass
+
+ if self.instance and parent_object_type_id != self.instance.parent_object_type_id:
+ self.initial['parent'] = None
+
+ def clean(self):
+ super().clean()
+ self.instance.parent = self.cleaned_data.get('parent')
+
class ServiceCreateForm(ServiceForm):
service_template = DynamicModelChoiceField(
@@ -816,10 +843,7 @@ class ServiceCreateForm(ServiceForm):
fieldsets = (
FieldSet(
- TabbedGroups(
- FieldSet('device', name=_('Device')),
- FieldSet('virtual_machine', name=_('Virtual Machine')),
- ),
+ 'parent_object_type', 'parent',
TabbedGroups(
FieldSet('service_template', name=_('From Template')),
FieldSet('name', 'protocol', 'ports', name=_('Custom')),
@@ -830,8 +854,8 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta):
fields = [
- 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
- 'comments', 'tags',
+ 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
+ 'comments', 'tags', 'parent_object_type',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/ipam/graphql/enums.py b/netbox/ipam/graphql/enums.py
new file mode 100644
index 000000000..34180bfbc
--- /dev/null
+++ b/netbox/ipam/graphql/enums.py
@@ -0,0 +1,27 @@
+import strawberry
+
+from ipam.choices import *
+
+__all__ = (
+ 'FHRPGroupAuthTypeEnum',
+ 'FHRPGroupProtocolEnum',
+ 'IPAddressFamilyEnum',
+ 'IPAddressRoleEnum',
+ 'IPAddressStatusEnum',
+ 'IPRangeStatusEnum',
+ 'PrefixStatusEnum',
+ 'ServiceProtocolEnum',
+ 'VLANStatusEnum',
+ 'VLANQinQRoleEnum',
+)
+
+FHRPGroupAuthTypeEnum = strawberry.enum(FHRPGroupAuthTypeChoices.as_enum(prefix='authentication'))
+FHRPGroupProtocolEnum = strawberry.enum(FHRPGroupProtocolChoices.as_enum(prefix='protocol'))
+IPAddressFamilyEnum = strawberry.enum(IPAddressFamilyChoices.as_enum(prefix='family'))
+IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum(prefix='role'))
+IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum(prefix='status'))
+IPRangeStatusEnum = strawberry.enum(IPRangeStatusChoices.as_enum(prefix='status'))
+PrefixStatusEnum = strawberry.enum(PrefixStatusChoices.as_enum(prefix='status'))
+ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum(prefix='role'))
+VLANStatusEnum = strawberry.enum(VLANStatusChoices.as_enum(prefix='status'))
+VLANQinQRoleEnum = strawberry.enum(VLANQinQRoleChoices.as_enum(prefix='role'))
diff --git a/netbox/ipam/graphql/filter_mixins.py b/netbox/ipam/graphql/filter_mixins.py
new file mode 100644
index 000000000..511850285
--- /dev/null
+++ b/netbox/ipam/graphql/filter_mixins.py
@@ -0,0 +1,25 @@
+from dataclasses import dataclass
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+
+from core.graphql.filter_mixins import BaseFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerLookup
+ from .enums import *
+
+__all__ = (
+ 'ServiceBaseFilterMixin',
+)
+
+
+@dataclass
+class ServiceBaseFilterMixin(BaseFilterMixin):
+ protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py
index 1b0e0133b..4e1afc3ba 100644
--- a/netbox/ipam/graphql/filters.py
+++ b/netbox/ipam/graphql/filters.py
@@ -1,7 +1,28 @@
-import strawberry_django
+from datetime import date
+from typing import Annotated, TYPE_CHECKING
-from ipam import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import netaddr
+import strawberry
+import strawberry_django
+from django.db.models import Q
+from netaddr.core import AddrFormatError
+from strawberry.scalars import ID
+from strawberry_django import FilterLookup, DateFilterLookup
+
+from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from dcim.graphql.filter_mixins import ScopedFilterMixin
+from ipam import models
+from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
+from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
+from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
+ from circuits.graphql.filters import ProviderFilter
+ from core.graphql.filters import ContentTypeFilter
+ from dcim.graphql.filters import SiteFilter
+ from vpn.graphql.filters import L2VPNFilter
+ from .enums import *
__all__ = (
'ASNFilter',
@@ -26,108 +47,273 @@ __all__ = (
@strawberry_django.filter(models.ASN, lookups=True)
-@autotype_decorator(filtersets.ASNFilterSet)
-class ASNFilter(BaseFilterMixin):
- pass
+class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ sites: (
+ Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
+ providers: (
+ Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None
+ ) = strawberry_django.filter_field()
@strawberry_django.filter(models.ASNRange, lookups=True)
-@autotype_decorator(filtersets.ASNRangeFilterSet)
-class ASNRangeFilter(BaseFilterMixin):
- pass
+class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Aggregate, lookups=True)
-@autotype_decorator(filtersets.AggregateFilterSet)
-class AggregateFilter(BaseFilterMixin):
- pass
+class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ prefix_id: ID | None = strawberry_django.filter_field()
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FHRPGroup, lookups=True)
-@autotype_decorator(filtersets.FHRPGroupFilterSet)
-class FHRPGroupFilter(BaseFilterMixin):
- pass
+class FHRPGroupFilter(PrimaryModelFilterMixin):
+ group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ auth_key: FilterLookup[str] | None = strawberry_django.filter_field()
+ ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True)
-@autotype_decorator(filtersets.FHRPGroupAssignmentFilterSet)
-class FHRPGroupAssignmentFilter(BaseFilterMixin):
- pass
+class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ interface_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: ID | None = strawberry_django.filter_field()
+ priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.IPAddress, lookups=True)
-@autotype_decorator(filtersets.IPAddressFilterSet)
-class IPAddressFilter(BaseFilterMixin):
- pass
+class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ address: FilterLookup[str] | None = strawberry_django.filter_field()
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_id: ID | None = strawberry_django.filter_field()
+ nat_inside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ nat_inside_id: ID | None = strawberry_django.filter_field()
+ nat_outside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ nat_outside_id: ID | None = strawberry_django.filter_field()
+ dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
+
+ @strawberry_django.filter_field()
+ def parent(self, value: list[str], prefix) -> Q:
+ if not value:
+ return Q()
+ q = Q()
+ for subnet in value:
+ try:
+ query = str(netaddr.IPNetwork(subnet.strip()).cidr)
+ q |= Q(address__net_host_contained=query)
+ except (AddrFormatError, ValueError):
+ return Q()
+ return q
@strawberry_django.filter(models.IPRange, lookups=True)
-@autotype_decorator(filtersets.IPRangeFilterSet)
-class IPRangeFilter(BaseFilterMixin):
- pass
+class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ start_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ end_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+ @strawberry_django.filter_field()
+ def parent(self, value: list[str], prefix) -> Q:
+ if not value:
+ return Q()
+ q = Q()
+ for subnet in value:
+ try:
+ query = str(netaddr.IPNetwork(subnet.strip()).cidr)
+ q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
+ except (AddrFormatError, ValueError):
+ return Q()
+ return q
@strawberry_django.filter(models.Prefix, lookups=True)
-@autotype_decorator(filtersets.PrefixFilterSet)
-class PrefixFilter(BaseFilterMixin):
- pass
+class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ prefix: FilterLookup[str] | None = strawberry_django.filter_field()
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vlan_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ role_id: ID | None = strawberry_django.filter_field()
+ is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()
+ mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RIR, lookups=True)
-@autotype_decorator(filtersets.RIRFilterSet)
-class RIRFilter(BaseFilterMixin):
- pass
+class RIRFilter(OrganizationalModelFilterMixin):
+ is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Role, lookups=True)
-@autotype_decorator(filtersets.RoleFilterSet)
-class RoleFilter(BaseFilterMixin):
- pass
+class RoleFilter(OrganizationalModelFilterMixin):
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.RouteTarget, lookups=True)
-@autotype_decorator(filtersets.RouteTargetFilterSet)
-class RouteTargetFilter(BaseFilterMixin):
- pass
+class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ exporting_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ importing_l2vpns: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ exporting_l2vpns: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Service, lookups=True)
-@autotype_decorator(filtersets.ServiceFilterSet)
-class ServiceFilter(BaseFilterMixin):
- pass
+class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ parent_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ parent_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ServiceTemplate, lookups=True)
-@autotype_decorator(filtersets.ServiceTemplateFilterSet)
-class ServiceTemplateFilter(BaseFilterMixin):
- pass
+class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLAN, lookups=True)
-@autotype_decorator(filtersets.VLANFilterSet)
-class VLANFilter(BaseFilterMixin):
- pass
+class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: ID | None = strawberry_django.filter_field()
+ vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field()
+ role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ role_id: ID | None = strawberry_django.filter_field()
+ qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ qinq_svlan_id: ID | None = strawberry_django.filter_field()
+ qinq_cvlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VLANGroup, lookups=True)
-@autotype_decorator(filtersets.VLANGroupFilterSet)
-class VLANGroupFilter(BaseFilterMixin):
- pass
+class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
+ vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True)
-@autotype_decorator(filtersets.VLANTranslationPolicyFilterSet)
-class VLANTranslationPolicyFilter(BaseFilterMixin):
- pass
+class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLANTranslationRule, lookups=True)
-@autotype_decorator(filtersets.VLANTranslationRuleFilterSet)
-class VLANTranslationRuleFilter(BaseFilterMixin):
- pass
+class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
+ policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ policy_id: ID | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ remote_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VRF, lookups=True)
-@autotype_decorator(filtersets.VRFFilterSet)
-class VRFFilter(BaseFilterMixin):
- pass
+class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ rd: FilterLookup[str] | None = strawberry_django.filter_field()
+ enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
+ import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ export_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
index 54ce2fc74..e8f98eabe 100644
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List, Union
+from typing import Annotated, List, TYPE_CHECKING, Union
import strawberry
import strawberry_django
@@ -12,6 +12,21 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa
from .filters import *
from .mixins import IPAddressesMixin
+if TYPE_CHECKING:
+ from dcim.graphql.types import (
+ DeviceType,
+ InterfaceType,
+ LocationType,
+ RackType,
+ RegionType,
+ SiteGroupType,
+ SiteType,
+ )
+ from tenancy.graphql.types import TenantType
+ from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType, VirtualMachineType
+ from vpn.graphql.types import L2VPNType, TunnelTerminationType
+ from wireless.graphql.types import WirelessLANType
+
__all__ = (
'ASNType',
'ASNRangeType',
@@ -56,7 +71,8 @@ class BaseIPAddressFamilyType:
@strawberry_django.type(
models.ASN,
fields='__all__',
- filters=ASNFilter
+ filters=ASNFilter,
+ pagination=True
)
class ASNType(NetBoxObjectType):
asn: BigInt
@@ -70,7 +86,8 @@ class ASNType(NetBoxObjectType):
@strawberry_django.type(
models.ASNRange,
fields='__all__',
- filters=ASNRangeFilter
+ filters=ASNRangeFilter,
+ pagination=True
)
class ASNRangeType(NetBoxObjectType):
start: BigInt
@@ -82,7 +99,8 @@ class ASNRangeType(NetBoxObjectType):
@strawberry_django.type(
models.Aggregate,
fields='__all__',
- filters=AggregateFilter
+ filters=AggregateFilter,
+ pagination=True
)
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
@@ -93,7 +111,8 @@ class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
@strawberry_django.type(
models.FHRPGroup,
fields='__all__',
- filters=FHRPGroupFilter
+ filters=FHRPGroupFilter,
+ pagination=True
)
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
@@ -102,8 +121,9 @@ class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
@strawberry_django.type(
models.FHRPGroupAssignment,
- exclude=('interface_type', 'interface_id'),
- filters=FHRPGroupAssignmentFilter
+ exclude=['interface_type', 'interface_id'],
+ filters=FHRPGroupAssignmentFilter,
+ pagination=True
)
class FHRPGroupAssignmentType(BaseObjectType):
group: Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')]
@@ -118,8 +138,9 @@ class FHRPGroupAssignmentType(BaseObjectType):
@strawberry_django.type(
models.IPAddress,
- exclude=('assigned_object_type', 'assigned_object_id', 'address'),
- filters=IPAddressFilter
+ exclude=['assigned_object_type', 'assigned_object_id', 'address'],
+ filters=IPAddressFilter,
+ pagination=True
)
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
address: str
@@ -143,7 +164,8 @@ class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
@strawberry_django.type(
models.IPRange,
fields='__all__',
- filters=IPRangeFilter
+ filters=IPRangeFilter,
+ pagination=True
)
class IPRangeType(NetBoxObjectType, ContactsMixin):
start_address: str
@@ -155,8 +177,9 @@ class IPRangeType(NetBoxObjectType, ContactsMixin):
@strawberry_django.type(
models.Prefix,
- exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
- filters=PrefixFilter
+ exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
+ filters=PrefixFilter,
+ pagination=True
)
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
prefix: str
@@ -178,7 +201,8 @@ class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
@strawberry_django.type(
models.RIR,
fields='__all__',
- filters=RIRFilter
+ filters=RIRFilter,
+ pagination=True
)
class RIRType(OrganizationalObjectType):
@@ -190,7 +214,8 @@ class RIRType(OrganizationalObjectType):
@strawberry_django.type(
models.Role,
fields='__all__',
- filters=RoleFilter
+ filters=RoleFilter,
+ pagination=True
)
class RoleType(OrganizationalObjectType):
@@ -202,7 +227,8 @@ class RoleType(OrganizationalObjectType):
@strawberry_django.type(
models.RouteTarget,
fields='__all__',
- filters=RouteTargetFilter
+ filters=RouteTargetFilter,
+ pagination=True
)
class RouteTargetType(NetBoxObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -215,21 +241,28 @@ class RouteTargetType(NetBoxObjectType):
@strawberry_django.type(
models.Service,
- fields='__all__',
- filters=ServiceFilter
+ exclude=('parent_object_type', 'parent_object_id'),
+ filters=ServiceFilter,
+ pagination=True
)
class ServiceType(NetBoxObjectType, ContactsMixin):
ports: List[int]
- device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
- virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
-
ipaddresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]]
+ @strawberry_django.field
+ def parent(self) -> Annotated[Union[
+ Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')],
+ Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')],
+ Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')],
+ ], strawberry.union("ServiceParentType")] | None:
+ return self.parent
+
@strawberry_django.type(
models.ServiceTemplate,
fields='__all__',
- filters=ServiceTemplateFilter
+ filters=ServiceTemplateFilter,
+ pagination=True
)
class ServiceTemplateType(NetBoxObjectType):
ports: List[int]
@@ -237,8 +270,9 @@ class ServiceTemplateType(NetBoxObjectType):
@strawberry_django.type(
models.VLAN,
- exclude=('qinq_svlan',),
- filters=VLANFilter
+ exclude=['qinq_svlan'],
+ filters=VLANFilter,
+ pagination=True
)
class VLANType(NetBoxObjectType):
site: Annotated["SiteType", strawberry.lazy('ipam.graphql.types')] | None
@@ -260,13 +294,15 @@ class VLANType(NetBoxObjectType):
@strawberry_django.type(
models.VLANGroup,
- exclude=('scope_type', 'scope_id'),
- filters=VLANGroupFilter
+ exclude=['scope_type', 'scope_id'],
+ filters=VLANGroupFilter,
+ pagination=True
)
class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType]
vid_ranges: List[str]
+ tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.field
def scope(self) -> Annotated[Union[
@@ -284,7 +320,8 @@ class VLANGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.VLANTranslationPolicy,
fields='__all__',
- filters=VLANTranslationPolicyFilter
+ filters=VLANTranslationPolicyFilter,
+ pagination=True
)
class VLANTranslationPolicyType(NetBoxObjectType):
rules: List[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]]
@@ -293,7 +330,8 @@ class VLANTranslationPolicyType(NetBoxObjectType):
@strawberry_django.type(
models.VLANTranslationRule,
fields='__all__',
- filters=VLANTranslationRuleFilter
+ filters=VLANTranslationRuleFilter,
+ pagination=True
)
class VLANTranslationRuleType(NetBoxObjectType):
policy: Annotated[
@@ -305,7 +343,8 @@ class VLANTranslationRuleType(NetBoxObjectType):
@strawberry_django.type(
models.VRF,
fields='__all__',
- filters=VRFFilter
+ filters=VRFFilter,
+ pagination=True
)
class VRFType(NetBoxObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py
index 896d7c4c9..15fb71dde 100644
--- a/netbox/ipam/migrations/0001_squashed.py
+++ b/netbox/ipam/migrations/0001_squashed.py
@@ -13,9 +13,9 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
- ('dcim', '0002_auto_20160622_1821'),
- ('extras', '0001_initial'),
- ('tenancy', '0001_initial'),
+ ('dcim', '0002_squashed'),
+ ('extras', '0001_squashed'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
@@ -195,12 +195,6 @@ class Migration(migrations.Migration):
'scope_type',
models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- (
- 'model__in',
- ('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'),
- )
- ),
null=True,
on_delete=django.db.models.deletion.CASCADE,
to='contenttypes.contenttype',
diff --git a/netbox/ipam/migrations/0002_squashed_0046.py b/netbox/ipam/migrations/0002_squashed_0046.py
index 6c03753d8..43b1223d0 100644
--- a/netbox/ipam/migrations/0002_squashed_0046.py
+++ b/netbox/ipam/migrations/0002_squashed_0046.py
@@ -5,12 +5,12 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0003_auto_20160628_1721'),
- ('virtualization', '0001_virtualization'),
+ ('dcim', '0003_squashed_0130'),
+ ('virtualization', '0001_squashed_0022'),
('contenttypes', '0002_remove_content_type_name'),
- ('ipam', '0001_initial'),
- ('extras', '0002_custom_fields'),
- ('tenancy', '0001_initial'),
+ ('ipam', '0001_squashed'),
+ ('extras', '0002_squashed_0059'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
@@ -154,13 +154,6 @@ class Migration(migrations.Migration):
name='assigned_object_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0047_squashed_0053.py b/netbox/ipam/migrations/0047_squashed_0053.py
index a05d0cb81..151792eb6 100644
--- a/netbox/ipam/migrations/0047_squashed_0053.py
+++ b/netbox/ipam/migrations/0047_squashed_0053.py
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('ipam', '0046_set_vlangroup_scope_types'),
+ ('ipam', '0002_squashed_0046'),
('tenancy', '0001_squashed_0012'),
('extras', '0002_squashed_0059'),
('contenttypes', '0002_remove_content_type_name'),
@@ -136,14 +136,6 @@ class Migration(migrations.Migration):
name='assigned_object_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0054_squashed_0067.py b/netbox/ipam/migrations/0054_squashed_0067.py
index 929a27fda..26bd54115 100644
--- a/netbox/ipam/migrations/0054_squashed_0067.py
+++ b/netbox/ipam/migrations/0054_squashed_0067.py
@@ -28,11 +28,11 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('tenancy', '0007_contact_link'),
+ ('tenancy', '0002_squashed_0011'),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0060_squashed_0086'),
- ('ipam', '0053_asn_model'),
- ('tenancy', '0009_standardize_description_comments'),
+ ('ipam', '0047_squashed_0053'),
+ ('tenancy', '0002_squashed_0011'),
]
operations = [
@@ -304,14 +304,6 @@ class Migration(migrations.Migration):
(
'assigned_object_type',
models.ForeignKey(
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'ipam'), ('model', 'vlan')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
diff --git a/netbox/ipam/migrations/0068_move_l2vpn.py b/netbox/ipam/migrations/0068_move_l2vpn.py
index 9240240bc..16935b1a6 100644
--- a/netbox/ipam/migrations/0068_move_l2vpn.py
+++ b/netbox/ipam/migrations/0068_move_l2vpn.py
@@ -16,7 +16,7 @@ def update_content_types(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('ipam', '0067_ipaddress_index_host'),
+ ('ipam', '0054_squashed_0067'),
]
operations = [
diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py
index 2ab54d023..47a971750 100644
--- a/netbox/ipam/migrations/0071_prefix_scope.py
+++ b/netbox/ipam/migrations/0071_prefix_scope.py
@@ -33,7 +33,6 @@ class Migration(migrations.Migration):
name='scope_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0077_vlangroup_tenant.py b/netbox/ipam/migrations/0077_vlangroup_tenant.py
new file mode 100644
index 000000000..9fb67cf53
--- /dev/null
+++ b/netbox/ipam/migrations/0077_vlangroup_tenant.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.1.3 on 2025-02-20 17:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0076_natural_ordering'),
+ ('tenancy', '0017_natural_ordering'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vlangroup',
+ name='tenant',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='vlan_groups',
+ to='tenancy.tenant',
+ ),
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0078_iprange_mark_utilized.py b/netbox/ipam/migrations/0078_iprange_mark_utilized.py
new file mode 100644
index 000000000..95da5387b
--- /dev/null
+++ b/netbox/ipam/migrations/0078_iprange_mark_utilized.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0077_vlangroup_tenant'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='iprange',
+ name='mark_populated',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0079_add_service_fhrp_group_parent_gfk.py b/netbox/ipam/migrations/0079_add_service_fhrp_group_parent_gfk.py
new file mode 100644
index 000000000..4ae5fd271
--- /dev/null
+++ b/netbox/ipam/migrations/0079_add_service_fhrp_group_parent_gfk.py
@@ -0,0 +1,29 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('ipam', '0078_iprange_mark_utilized'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='service',
+ name='parent_object_id',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='service',
+ name='parent_object_type',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='contenttypes.contenttype'
+ ),
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0080_populate_service_parent.py b/netbox/ipam/migrations/0080_populate_service_parent.py
new file mode 100644
index 000000000..78f3086fc
--- /dev/null
+++ b/netbox/ipam/migrations/0080_populate_service_parent.py
@@ -0,0 +1,54 @@
+from django.db import migrations
+from django.db.models import F
+
+
+def populate_service_parent_gfk(apps, schema_config):
+ Service = apps.get_model('ipam', 'Service')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ Device = apps.get_model('dcim', 'device')
+ VirtualMachine = apps.get_model('virtualization', 'virtualmachine')
+
+ Service.objects.filter(device_id__isnull=False).update(
+ parent_object_type=ContentType.objects.get_for_model(Device),
+ parent_object_id=F('device_id'),
+ )
+
+ Service.objects.filter(virtual_machine_id__isnull=False).update(
+ parent_object_type=ContentType.objects.get_for_model(VirtualMachine),
+ parent_object_id=F('virtual_machine_id'),
+ )
+
+
+def repopulate_device_and_virtualmachine_relations(apps, schemaconfig):
+ Service = apps.get_model('ipam', 'Service')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ Device = apps.get_model('dcim', 'device')
+ VirtualMachine = apps.get_model('virtualization', 'virtualmachine')
+
+ Service.objects.filter(
+ parent_object_type=ContentType.objects.get_for_model(Device),
+ ).update(
+ device_id=F('parent_object_id')
+ )
+
+ Service.objects.filter(
+ parent_object_type=ContentType.objects.get_for_model(VirtualMachine),
+ ).update(
+ virtual_machine_id=F('parent_object_id')
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0202_location_comments_region_comments_sitegroup_comments'),
+ ('ipam', '0079_add_service_fhrp_group_parent_gfk'),
+ ('virtualization', '0048_populate_mac_addresses'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ populate_service_parent_gfk,
+ reverse_code=repopulate_device_and_virtualmachine_relations,
+ )
+ ]
diff --git a/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py b/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py
new file mode 100644
index 000000000..03b63cd12
--- /dev/null
+++ b/netbox/ipam/migrations/0081_remove_service_device_virtual_machine_add_parent_gfk_index.py
@@ -0,0 +1,39 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('extras', '0126_exporttemplate_file_name'),
+ ('ipam', '0080_populate_service_parent'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='service',
+ name='device',
+ ),
+ migrations.RemoveField(
+ model_name='service',
+ name='virtual_machine',
+ ),
+ migrations.AlterField(
+ model_name='service',
+ name='parent_object_id',
+ field=models.PositiveBigIntegerField(),
+ ),
+ migrations.AlterField(
+ model_name='service',
+ name='parent_object_type',
+ field=models.ForeignKey(
+ on_delete=models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'
+ ),
+ ),
+ migrations.AddIndex(
+ model_name='service',
+ index=models.Index(
+ fields=['parent_object_type', 'parent_object_id'], name='ipam_servic_parent__563d2b_idx'
+ ),
+ ),
+ ]
diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py
index f5982853e..63a04d4d1 100644
--- a/netbox/ipam/models/fhrp.py
+++ b/netbox/ipam/models/fhrp.py
@@ -48,6 +48,12 @@ class FHRPGroup(PrimaryModel):
object_id_field='assigned_object_id',
related_query_name='fhrpgroup'
)
+ services = GenericRelation(
+ to='ipam.Service',
+ content_type_field='parent_object_type',
+ object_id_field='parent_object_id',
+ related_query_name='fhrpgroup',
+ )
clone_fields = ('protocol', 'auth_type', 'auth_key', 'description')
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index e1a8d91e3..ab2481d90 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -383,14 +383,15 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
else:
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
- def get_child_ranges(self):
+ def get_child_ranges(self, **kwargs):
"""
Return all IPRanges within this Prefix and VRF.
"""
return IPRange.objects.filter(
vrf=self.vrf,
start_address__net_host_contained=str(self.prefix),
- end_address__net_host_contained=str(self.prefix)
+ end_address__net_host_contained=str(self.prefix),
+ **kwargs
)
def get_child_ips(self):
@@ -407,15 +408,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
"""
Return all available IPs within this prefix as an IPSet.
"""
- if self.mark_utilized:
- return netaddr.IPSet()
-
prefix = netaddr.IPSet(self.prefix)
- child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
- child_ranges = []
- for iprange in self.get_child_ranges():
- child_ranges.append(iprange.range)
- available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
+ child_ips = netaddr.IPSet([
+ ip.address.ip for ip in self.get_child_ips()
+ ])
+ child_ranges = netaddr.IPSet([
+ iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
+ ])
+ available_ips = prefix - child_ips - child_ranges
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
@@ -433,6 +433,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
# For IPv6 prefixes, omit the Subnet-Router anycast address
# per RFC 4291
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
+
return available_ips
def get_first_available_ip(self):
@@ -461,9 +462,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
utilization = float(child_prefixes.size) / self.prefix.size * 100
else:
# Compile an IPSet to avoid counting duplicate IPs
- child_ips = netaddr.IPSet(
- [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.get_child_ips()]
- )
+ child_ips = netaddr.IPSet()
+ for iprange in self.get_child_ranges().filter(mark_utilized=True):
+ child_ips.add(iprange.range)
+ for ip in self.get_child_ips():
+ child_ips.add(ip.address.ip)
prefix_size = self.prefix.size
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
@@ -519,14 +522,19 @@ class IPRange(ContactsMixin, PrimaryModel):
null=True,
help_text=_('The primary function of this range')
)
+ mark_populated = models.BooleanField(
+ verbose_name=_('mark populated'),
+ default=False,
+ help_text=_("Prevent the creation of IP addresses within this range")
+ )
mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'),
default=False,
- help_text=_("Treat as fully utilized")
+ help_text=_("Report space as 100% utilized")
)
clone_fields = (
- 'vrf', 'tenant', 'status', 'role', 'description',
+ 'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
)
class Meta:
@@ -663,6 +671,9 @@ class IPRange(ContactsMixin, PrimaryModel):
"""
Return all available IPs within this range as an IPSet.
"""
+ if self.mark_populated:
+ return netaddr.IPSet()
+
range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
@@ -742,7 +753,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
)
assigned_object_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
@@ -876,6 +886,20 @@ class IPAddress(ContactsMixin, PrimaryModel):
)
})
+ # Disallow the creation of IPAddresses within an IPRange with mark_populated=True
+ parent_range = IPRange.objects.filter(
+ start_address__lte=self.address,
+ end_address__gte=self.address,
+ vrf=self.vrf,
+ mark_populated=True
+ ).first()
+ if parent_range:
+ raise ValidationError({
+ 'address': _(
+ "Cannot create IP address {ip} inside range {range}."
+ ).format(ip=self.address, range=parent_range)
+ })
+
if self._original_assigned_object_id and self._original_assigned_object_type_id:
parent = getattr(self.assigned_object, 'parent_object', None)
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py
index bb4049781..2afd16076 100644
--- a/netbox/ipam/models/services.py
+++ b/netbox/ipam/models/services.py
@@ -1,5 +1,5 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
-from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -64,21 +64,17 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='services',
- verbose_name=_('device'),
- null=True,
- blank=True
+ parent_object_type = models.ForeignKey(
+ to='contenttypes.ContentType',
+ on_delete=models.PROTECT,
+ related_name='+',
)
- virtual_machine = models.ForeignKey(
- to='virtualization.VirtualMachine',
- on_delete=models.CASCADE,
- related_name='services',
- null=True,
- blank=True
+ parent_object_id = models.PositiveBigIntegerField()
+ parent = GenericForeignKey(
+ ct_field='parent_object_type',
+ fk_field='parent_object_id'
)
+
name = models.CharField(
max_length=100,
verbose_name=_('name')
@@ -91,22 +87,12 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
help_text=_("The specific IP addresses (if any) to which this service is bound")
)
- clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
+ clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ]
class Meta:
+ indexes = (
+ models.Index(fields=('parent_object_type', 'parent_object_id')),
+ )
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
verbose_name = _('service')
verbose_name_plural = _('services')
-
- @property
- def parent(self):
- return self.device or self.virtual_machine
-
- def clean(self):
- super().clean()
-
- # A Service must belong to a Device *or* to a VirtualMachine
- if self.device and self.virtual_machine:
- raise ValidationError(_("A service cannot be associated with both a device and a virtual machine."))
- if not self.device and not self.virtual_machine:
- raise ValidationError(_("A service must be associated with either a device or a virtual machine."))
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 0288daf2e..67c9d9414 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -46,7 +46,6 @@ class VLANGroup(OrganizationalModel):
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.CASCADE,
- limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
blank=True,
null=True
)
@@ -63,6 +62,13 @@ class VLANGroup(OrganizationalModel):
verbose_name=_('VLAN ID ranges'),
default=default_vid_ranges
)
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='vlan_groups',
+ blank=True,
+ null=True
+ )
_total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py
index 6e71d44a5..63437e417 100644
--- a/netbox/ipam/search.py
+++ b/netbox/ipam/search.py
@@ -123,7 +123,7 @@ class ServiceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
- display_attrs = ('device', 'virtual_machine', 'description')
+ display_attrs = ('parent', 'description')
@register_search
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index 1eefa6b3a..03365a442 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -10,6 +10,7 @@ from .template_code import *
__all__ = (
'AggregateTable',
+ 'AnnotatedIPAddressTable',
'AssignedIPAddressesTable',
'IPAddressAssignTable',
'IPAddressTable',
@@ -268,6 +269,10 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Role'),
linkify=True
)
+ mark_populated = columns.BooleanColumn(
+ verbose_name=_('Marked Populated'),
+ false_mark=None
+ )
mark_utilized = columns.BooleanColumn(
verbose_name=_('Marked Utilized'),
false_mark=None
@@ -288,7 +293,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
- 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated',
+ 'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created',
+ 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -369,6 +375,16 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
}
+class AnnotatedIPAddressTable(IPAddressTable):
+ address = tables.TemplateColumn(
+ template_code=IPADDRESS_LINK,
+ verbose_name=_('IP Address')
+ )
+
+ class Meta(IPAddressTable.Meta):
+ pass
+
+
class IPAddressAssignTable(NetBoxTable):
address = tables.TemplateColumn(
template_code=IPADDRESS_ASSIGN_LINK,
diff --git a/netbox/ipam/tables/template_code.py b/netbox/ipam/tables/template_code.py
index fb969345e..14b73b28d 100644
--- a/netbox/ipam/tables/template_code.py
+++ b/netbox/ipam/tables/template_code.py
@@ -25,18 +25,23 @@ PREFIX_LINK_WITH_DEPTH = """
{% endif %}
""" + PREFIX_LINK
+# Annotate the ID of each IP address for copy-to-clipboard functionality
IPADDRESS_LINK = """
-{% if record.pk %}
- {{ record.address }}
+{% if record.address %}
+ {{ record }}
+{% elif record.start_address %}
+ {{ record }}
{% elif perms.ipam.add_ipaddress %}
- {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
+ {{ record.title }}
{% else %}
- {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
+ {{ record.title }}
{% endif %}
"""
IPADDRESS_COPY_BUTTON = """
-{% copy_content record.pk prefix="ipaddress_" %}
+{% if record.address %}
+ {% copy_content record.pk prefix="ipaddress_" %}
+{% endif %}
"""
IPADDRESS_ASSIGN_LINK = """
diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py
index aa1900e41..c22975be0 100644
--- a/netbox/ipam/tables/vlans.py
+++ b/netbox/ipam/tables/vlans.py
@@ -28,7 +28,7 @@ AVAILABLE_LABEL = mark_safe('Available first_ip_in_prefix:
- skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix)
- first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
- output.append((skipped_count, first_skipped))
+ if records[0][0] > first_ip_in_prefix:
+ output.append(AvailableIPSpace(
+ size=int(records[0][0] - first_ip_in_prefix),
+ first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
+ ))
- # Iterate through existing IPs and annotate free ranges
- for ip in ipaddress_list:
+ # Add IP ranges & addresses, annotating available space in between records
+ for record in records:
if prev_ip:
- diff = int(ip.address.ip - prev_ip.address.ip)
- if diff > 1:
- first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
- output.append((diff - 1, first_skipped))
- output.append(ip)
- prev_ip = ip
+ # Annotate available space
+ if (diff := int(record[0]) - int(prev_ip)) > 1:
+ first_skipped = f'{prev_ip + 1}/{prefix.mask_length}'
+ output.append(AvailableIPSpace(
+ size=diff - 1,
+ first_ip=first_skipped
+ ))
+
+ output.append(record[1])
+
+ # Update the previous IP address
+ if hasattr(record[1], 'end_address'):
+ prev_ip = record[1].end_address.ip
+ else:
+ prev_ip = record[0]
# Include any remaining available IPs
- if prev_ip.address.ip < last_ip_in_prefix:
- skipped_count = int(last_ip_in_prefix - prev_ip.address.ip)
- first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
- output.append((skipped_count, first_skipped))
+ if prev_ip < last_ip_in_prefix:
+ output.append(AvailableIPSpace(
+ size=int(last_ip_in_prefix - prev_ip),
+ first_ip=f'{prev_ip + 1}/{prefix.mask_length}'
+ ))
return output
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index e1c6f170c..34ce6144f 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -8,21 +8,20 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
-from dcim.models import Interface, Site
+from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable
from netbox.views import generic
-from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
-from virtualization.models import VMInterface
+from virtualization.models import VirtualMachine, VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
from .models import *
-from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
+from .utils import add_requested_prefixes, add_available_vlans, annotate_ip_space
#
@@ -434,11 +433,6 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable
-@register_model_view(Aggregate, 'contacts')
-class AggregateContactsView(ObjectContactsView):
- queryset = Aggregate.objects.all()
-
-
#
# Prefix/VLAN roles
#
@@ -625,7 +619,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
class PrefixIPAddressesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all()
child_model = IPAddress
- table = tables.IPAddressTable
+ table = tables.AnnotatedIPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html'
@@ -641,7 +635,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q') and not get_table_ordering(request, self.table):
- return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
+ return annotate_ip_space(parent)
return queryset
def get_extra_context(self, request, instance):
@@ -684,11 +678,6 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
-@register_model_view(Prefix, 'contacts')
-class PrefixContactsView(ObjectContactsView):
- queryset = Prefix.objects.all()
-
-
#
# IP Ranges
#
@@ -778,11 +767,6 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
-@register_model_view(IPRange, 'contacts')
-class IPRangeContactsView(ObjectContactsView):
- queryset = IPRange.objects.all()
-
-
#
# IP addresses
#
@@ -965,11 +949,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
return parent.get_related_ips().restrict(request.user, 'view')
-@register_model_view(IPAddress, 'contacts')
-class IPAddressContactsView(ObjectContactsView):
- queryset = IPAddress.objects.all()
-
-
#
# VLAN groups
#
@@ -1182,7 +1161,7 @@ class FHRPGroupListView(generic.ObjectListView):
@register_model_view(FHRPGroup)
-class FHRPGroupView(generic.ObjectView):
+class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance):
@@ -1194,6 +1173,18 @@ class FHRPGroupView(generic.ObjectView):
members_table.columns.hide('group')
return {
+ 'related_models': self.get_related_models(
+ request, instance,
+ extra=(
+ (
+ Service.objects.restrict(request.user, 'view').filter(
+ parent_object_type=ContentType.objects.get_for_model(FHRPGroup),
+ parent_object_id=instance.id,
+ ),
+ 'fhrpgroup_id'
+ ),
+ ),
+ ),
'members_table': members_table,
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
}
@@ -1430,7 +1421,7 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Service, 'list', path='', detail=False)
class ServiceListView(generic.ObjectListView):
- queryset = Service.objects.prefetch_related('device', 'virtual_machine')
+ queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
@@ -1440,6 +1431,18 @@ class ServiceListView(generic.ObjectListView):
class ServiceView(generic.ObjectView):
queryset = Service.objects.all()
+ def get_extra_context(self, request, instance):
+ context = {}
+ match instance.parent:
+ case Device():
+ context['breadcrumb_queryparam'] = 'device_id'
+ case VirtualMachine():
+ context['breadcrumb_queryparam'] = 'virtual_machine_id'
+ case FHRPGroup():
+ context['breadcrumb_queryparam'] = 'fhrpgroup_id'
+
+ return context
+
@register_model_view(Service, 'add', detail=False)
class ServiceCreateView(generic.ObjectEditView):
@@ -1466,7 +1469,7 @@ class ServiceBulkImportView(generic.BulkImportView):
@register_model_view(Service, 'bulk_edit', path='edit', detail=False)
class ServiceBulkEditView(generic.BulkEditView):
- queryset = Service.objects.prefetch_related('device', 'virtual_machine')
+ queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
form = forms.ServiceBulkEditForm
@@ -1474,11 +1477,6 @@ class ServiceBulkEditView(generic.BulkEditView):
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
- queryset = Service.objects.prefetch_related('device', 'virtual_machine')
+ queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
-
-
-@register_model_view(Service, 'contacts')
-class ServiceContactsView(ObjectContactsView):
- queryset = Service.objects.all()
diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py
index e7d1ef574..db5ec184d 100644
--- a/netbox/netbox/api/fields.py
+++ b/netbox/netbox/api/fields.py
@@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
__all__ = (
+ 'AttributesField',
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
@@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
def to_representation(self, instance):
return instance.lower, instance.upper - 1
+
+
+class AttributesField(serializers.JSONField):
+ """
+ Custom attributes stored as JSON data.
+ """
+ def to_internal_value(self, data):
+ data = super().to_internal_value(data)
+
+ # If updating an object, start with the initial attribute data. This enables the client to modify
+ # individual attributes without having to rewrite the entire field.
+ if data and self.parent.instance:
+ initial_data = getattr(self.parent.instance, self.source, None) or {}
+ return {**initial_data, **data}
+
+ return data
diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index d58d1affe..1befda371 100644
--- a/netbox/netbox/api/views.py
+++ b/netbox/netbox/api/views.py
@@ -4,15 +4,15 @@ from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django_rq.queues import get_connection
-from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rq.worker import Worker
-from netbox.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
+from netbox.plugins.utils import get_installed_plugins
class APIRootView(APIView):
@@ -66,7 +66,8 @@ class StatusView(APIView):
return Response({
'django-version': DJANGO_VERSION,
'installed-apps': installed_apps,
- 'netbox-version': settings.RELEASE.full_version,
+ 'netbox-version': settings.RELEASE.version,
+ 'netbox-full-version': settings.RELEASE.full_version,
'plugins': get_installed_plugins(),
'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')),
diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py
index e07e2c78b..e21be2348 100644
--- a/netbox/netbox/api/viewsets/mixins.py
+++ b/netbox/netbox/api/viewsets/mixins.py
@@ -45,7 +45,7 @@ class ExportTemplatesMixin:
if et is None:
raise Http404
queryset = self.filter_queryset(self.get_queryset())
- return et.render_to_response(queryset)
+ return et.render_to_response(queryset=queryset)
return super().list(request, *args, **kwargs)
diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py
index d9861545c..ada6b1293 100644
--- a/netbox/netbox/configuration_example.py
+++ b/netbox/netbox/configuration_example.py
@@ -12,14 +12,16 @@ ALLOWED_HOSTS = []
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
-DATABASE = {
- 'ENGINE': 'django.db.backends.postgresql', # Database engine
- 'NAME': 'netbox', # Database name
- 'USER': '', # PostgreSQL username
- 'PASSWORD': '', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql', # Database engine
+ 'NAME': 'netbox', # Database name
+ 'USER': '', # PostgreSQL username
+ 'PASSWORD': '', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age
+ }
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
@@ -164,6 +166,9 @@ LOGIN_REQUIRED = True
# re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None
+# Hide the login form. Useful when only allowing SSO authentication.
+LOGIN_FORM_HIDDEN = False
+
# The view name or URL to which users are redirected after logging out.
LOGOUT_REDIRECT_URL = 'home'
diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py
index cec05cabb..52973e94d 100644
--- a/netbox/netbox/configuration_testing.py
+++ b/netbox/netbox/configuration_testing.py
@@ -5,13 +5,15 @@
ALLOWED_HOSTS = ['*']
-DATABASE = {
- 'NAME': 'netbox',
- 'USER': 'netbox',
- 'PASSWORD': 'netbox',
- 'HOST': 'localhost',
- 'PORT': '',
- 'CONN_MAX_AGE': 300,
+DATABASES = {
+ 'default': {
+ 'NAME': 'netbox',
+ 'USER': 'netbox',
+ 'PASSWORD': 'netbox',
+ 'HOST': 'localhost',
+ 'PORT': '',
+ 'CONN_MAX_AGE': 300,
+ }
}
PLUGINS = [
@@ -41,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {}
+ALLOW_TOKEN_RETRIEVAL = True
+
LOGGING = {
'version': 1,
'disable_existing_loggers': True
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
index b8fbe7ad5..f24b4e11c 100644
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -1,3 +1,5 @@
+import json
+
import django_filters
from copy import deepcopy
from django.contrib.contenttypes.models import ContentType
@@ -10,7 +12,7 @@ from django.utils.translation import gettext as _
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange
from extras.choices import CustomFieldFilterLogicChoices
-from extras.filters import TagFilter
+from extras.filters import TagFilter, TagIDFilter
from extras.models import CustomField, SavedFilter
from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
@@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
from utilities import filters
__all__ = (
+ 'AttributeFiltersMixin',
'BaseFilterSet',
'ChangeLoggedModelFilterSet',
'NetBoxModelFilterSet',
@@ -286,6 +289,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
label=_('Search'),
)
tag = TagFilter()
+ tag_id = TagIDFilter()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -329,3 +333,48 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet):
models.Q(slug__icontains=value) |
models.Q(description__icontains=value)
)
+
+
+class NestedGroupModelFilterSet(NetBoxModelFilterSet):
+ """
+ A base FilterSet for models that inherit from NestedGroupModel
+ """
+ def search(self, queryset, name, value):
+ if value.strip():
+ queryset = queryset.filter(
+ models.Q(name__icontains=value) |
+ models.Q(slug__icontains=value) |
+ models.Q(description__icontains=value) |
+ models.Q(comments__icontains=value)
+ )
+
+ return queryset
+
+
+class AttributeFiltersMixin:
+ attributes_field_name = 'attribute_data'
+ attribute_filter_prefix = 'attr_'
+
+ def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
+ self.attr_filters = {}
+
+ # Extract JSONField-based filters from the incoming data
+ if data is not None:
+ for key, value in data.items():
+ if field := self._get_field_lookup(key):
+ # Attempt to cast the value to a native JSON type
+ try:
+ self.attr_filters[field] = json.loads(value)
+ except (ValueError, json.JSONDecodeError):
+ self.attr_filters[field] = value
+
+ super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
+
+ def _get_field_lookup(self, key):
+ if not key.startswith(self.attribute_filter_prefix):
+ return
+ lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix
+ return f'{self.attributes_field_name}__{lookup}'
+
+ def filter_queryset(self, queryset):
+ return super().filter_queryset(queryset).filter(**self.attr_filters)
diff --git a/netbox/netbox/graphql/enums.py b/netbox/netbox/graphql/enums.py
new file mode 100644
index 000000000..292a2e83e
--- /dev/null
+++ b/netbox/netbox/graphql/enums.py
@@ -0,0 +1,13 @@
+import strawberry
+
+from netbox.choices import *
+
+__all__ = (
+ 'ColorEnum',
+ 'DistanceUnitEnum',
+ 'WeightUnitEnum',
+)
+
+ColorEnum = strawberry.enum(ColorChoices.as_enum(prefix='color'))
+DistanceUnitEnum = strawberry.enum(DistanceUnitChoices.as_enum())
+WeightUnitEnum = strawberry.enum(WeightUnitChoices.as_enum())
diff --git a/netbox/netbox/graphql/filter_lookups.py b/netbox/netbox/graphql/filter_lookups.py
new file mode 100644
index 000000000..859236e4d
--- /dev/null
+++ b/netbox/netbox/graphql/filter_lookups.py
@@ -0,0 +1,219 @@
+from enum import Enum
+from typing import TypeVar, Tuple, Generic
+
+import strawberry
+import strawberry_django
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import Q, QuerySet
+from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
+from strawberry import ID
+from strawberry.types import Info
+from strawberry_django import (
+ ComparisonFilterLookup,
+ DateFilterLookup,
+ DatetimeFilterLookup,
+ FilterLookup,
+ RangeLookup,
+ TimeFilterLookup,
+ process_filters,
+)
+
+__all__ = (
+ 'ArrayLookup',
+ 'FloatArrayLookup',
+ 'FloatLookup',
+ 'IntegerArrayLookup',
+ 'IntegerLookup',
+ 'JSONFilter',
+ 'StringArrayLookup',
+ 'TreeNodeFilter',
+)
+
+T = TypeVar('T')
+SKIP_MSG = 'Filter will be skipped on `null` value'
+
+
+@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
+class JSONLookup:
+ string_lookup: FilterLookup[str] | None = strawberry_django.filter_field()
+ int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
+ int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
+ float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
+ float_comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
+ date_lookup: DateFilterLookup[str] | None = strawberry_django.filter_field()
+ datetime_lookup: DatetimeFilterLookup[str] | None = strawberry_django.filter_field()
+ time_lookup: TimeFilterLookup[str] | None = strawberry_django.filter_field()
+ boolean_lookup: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+
+@strawberry.input(one_of=True, description='Lookup for Integer fields. Only one of the lookup fields can be set.')
+class IntegerLookup:
+ filter_lookup: FilterLookup[int] | None = strawberry_django.filter_field()
+ range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
+ comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+
+
+@strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.')
+class FloatLookup:
+ filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field()
+ range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
+ comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+
+
+@strawberry.input
+class JSONFilter:
+ """
+ Class for JSON field lookups with paths
+ """
+
+ path: str
+ lookup: JSONLookup
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.lookup.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ json_path = f'{prefix}{self.path}__'
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path)
+
+
+@strawberry.enum
+class TreeNodeMatch(Enum):
+ EXACT = 'exact' # Just the node itself
+ DESCENDANTS = 'descendants' # Node and all descendants
+ SELF_AND_DESCENDANTS = 'self_and_descendants' # Node and all descendants
+ CHILDREN = 'children' # Just immediate children
+ SIBLINGS = 'siblings' # Nodes with same parent
+ ANCESTORS = 'ancestors' # All parent nodes
+ PARENT = 'parent' # Just immediate parent
+
+
+@strawberry.input
+class TreeNodeFilter:
+ id: ID
+ match_type: TreeNodeMatch
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ model_field_name = prefix.removesuffix('__').removesuffix('_id')
+ model_field = None
+ try:
+ model_field = queryset.model._meta.get_field(model_field_name)
+ except FieldDoesNotExist:
+ try:
+ model_field = queryset.model._meta.get_field(f'{model_field_name}s')
+ except FieldDoesNotExist:
+ return queryset, Q(pk__in=[])
+
+ if hasattr(model_field, 'related_model'):
+ related_model = model_field.related_model
+ else:
+ return queryset, Q(pk__in=[])
+
+ # Generate base Q filter for the related model without prefix
+ q_filter = generate_tree_node_q_filter(related_model, self)
+
+ # Handle different relationship types
+ if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
+ return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
+ elif isinstance(model_field, ForeignKey):
+ return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
+ elif isinstance(model_field, ManyToOneRel):
+ return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
+ else:
+ return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
+
+
+def generate_tree_node_q_filter(model_class, filter_value: TreeNodeFilter) -> Q:
+ """
+ Generate appropriate Q filter for MPTT tree filtering based on match type
+ """
+ try:
+ node = model_class.objects.get(id=filter_value.id)
+ except model_class.DoesNotExist:
+ return Q(pk__in=[])
+
+ if filter_value.match_type == TreeNodeMatch.EXACT:
+ return Q(id=filter_value.id)
+ elif filter_value.match_type == TreeNodeMatch.DESCENDANTS:
+ return Q(tree_id=node.tree_id, lft__gt=node.lft, rght__lt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.SELF_AND_DESCENDANTS:
+ return Q(tree_id=node.tree_id, lft__gte=node.lft, rght__lte=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.CHILDREN:
+ return Q(tree_id=node.tree_id, level=node.level + 1, lft__gt=node.lft, rght__lt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.SIBLINGS:
+ return Q(tree_id=node.tree_id, level=node.level, parent=node.parent) & ~Q(id=node.id)
+ elif filter_value.match_type == TreeNodeMatch.ANCESTORS:
+ return Q(tree_id=node.tree_id, lft__lt=node.lft, rght__gt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.PARENT:
+ return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
+ return Q()
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class ArrayLookup(Generic[T]):
+ """
+ Class for Array field lookups
+ """
+
+ contains: list[T] | None = strawberry_django.filter_field(description='Contains the value')
+ contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value')
+ overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value')
+ length: int | None = strawberry_django.filter_field(description='Length of the array')
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class IntegerArrayLookup(ArrayLookup[int]):
+ pass
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class FloatArrayLookup(ArrayLookup[float]):
+ pass
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class StringArrayLookup(ArrayLookup[str]):
+ pass
diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py
index 2044a1dde..b77238325 100644
--- a/netbox/netbox/graphql/filter_mixins.py
+++ b/netbox/netbox/graphql/filter_mixins.py
@@ -1,209 +1,104 @@
-from functools import partialmethod
-from typing import List
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TypeVar, TYPE_CHECKING, Annotated
-import django_filters
import strawberry
import strawberry_django
-from django.core.exceptions import FieldDoesNotExist
-from strawberry import auto
+from strawberry import ID
+from strawberry_django import FilterLookup, DatetimeFilterLookup
-from ipam.fields import ASNField
-from netbox.graphql.scalars import BigInt
-from utilities.fields import ColorField, CounterCacheField
-from utilities.filters import *
+from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
+
+__all__ = (
+ 'DistanceFilterMixin',
+ 'ImageAttachmentFilterMixin',
+ 'NestedGroupModelFilterMixin',
+ 'NetBoxModelFilterMixin',
+ 'OrganizationalModelFilterMixin',
+ 'PrimaryModelFilterMixin',
+ 'SyncedDataFilterMixin',
+ 'WeightFilterMixin',
+)
+
+T = TypeVar('T')
-def map_strawberry_type(field):
- should_create_function = False
- attr_type = None
-
- # NetBox Filter types - put base classes after derived classes
- if isinstance(field, ContentTypeFilter):
- should_create_function = True
- attr_type = str | None
- elif isinstance(field, MultiValueArrayFilter):
- pass
- elif isinstance(field, MultiValueCharFilter):
- # Note: Need to use the legacy FilterLookup from filters, not from
- # strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
- attr_type = strawberry_django.filters.FilterLookup[str] | None
- elif isinstance(field, MultiValueDateFilter):
- attr_type = auto
- elif isinstance(field, MultiValueDateTimeFilter):
- attr_type = auto
- elif isinstance(field, MultiValueDecimalFilter):
- pass
- elif isinstance(field, MultiValueMACAddressFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, MultiValueNumberFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, MultiValueTimeFilter):
- pass
- elif isinstance(field, MultiValueWWNFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, NullableCharFieldFilter):
- pass
- elif isinstance(field, NumericArrayFilter):
- should_create_function = True
- attr_type = int | None
- elif isinstance(field, TreeNodeMultipleChoiceFilter):
- should_create_function = True
- attr_type = List[str] | None
-
- # From django_filters - ordering of these matters as base classes must
- # come after derived classes so the base class doesn't get matched first
- # a pass for the check (no attr_type) means we don't currently handle
- # or use that type
- elif issubclass(type(field), django_filters.OrderingFilter):
- pass
- elif issubclass(type(field), django_filters.BaseRangeFilter):
- pass
- elif issubclass(type(field), django_filters.BaseInFilter):
- pass
- elif issubclass(type(field), django_filters.LookupChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.AllValuesMultipleFilter):
- pass
- elif issubclass(type(field), django_filters.AllValuesFilter):
- pass
- elif issubclass(type(field), django_filters.TimeRangeFilter):
- pass
- elif issubclass(type(field), django_filters.IsoDateTimeFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateTimeFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.RangeFilter):
- pass
- elif issubclass(type(field), django_filters.NumericRangeFilter):
- pass
- elif issubclass(type(field), django_filters.NumberFilter):
- should_create_function = True
- attr_type = int | None
- elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif issubclass(type(field), django_filters.ModelChoiceFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DurationFilter):
- pass
- elif issubclass(type(field), django_filters.IsoDateTimeFilter):
- pass
- elif issubclass(type(field), django_filters.DateTimeFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.TimeFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.DateFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.MultipleChoiceFilter):
- attr_type = str | None
- elif issubclass(type(field), django_filters.TypedChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.ChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.BooleanFilter):
- should_create_function = True
- attr_type = bool | None
- elif issubclass(type(field), django_filters.UUIDFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.CharFilter):
- # looks like only used by 'q'
- should_create_function = True
- attr_type = str | None
-
- return should_create_function, attr_type
+if TYPE_CHECKING:
+ from .enums import *
+ from core.graphql.filters import *
+ from extras.graphql.filters import *
-def autotype_decorator(filterset):
- """
- Decorator used to auto creates a dataclass used by Strawberry based on a filterset.
- Must go after the Strawberry decorator as follows:
-
- @strawberry_django.filter(models.Example, lookups=True)
- @autotype_decorator(filtersets.ExampleFilterSet)
- class ExampleFilter(BaseFilterMixin):
- pass
-
- The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields
- of the filterset, usually just a type specifier is generated, so for
- `fields = [created, ]` the dataclass would be:
-
- class ExampleFilter(BaseFilterMixin):
- created: auto
-
- For other filter fields a function needs to be created for Strawberry with the
- naming convention `filter_{fieldname}` which is auto detected and called by
- Strawberry, this function uses the filterset to handle the query.
- """
- def create_attribute_and_function(cls, fieldname, attr_type, should_create_function):
- if fieldname not in cls.__annotations__ and attr_type:
- cls.__annotations__[fieldname] = attr_type
-
- filter_name = f"filter_{fieldname}"
- if should_create_function and not hasattr(cls, filter_name):
- filter_by_filterset = getattr(cls, 'filter_by_filterset')
- setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname))
-
- def wrapper(cls):
- cls.filterset = filterset
- fields = filterset.get_fields()
- model = filterset._meta.model
- for fieldname in fields.keys():
- should_create_function = False
- attr_type = auto
- if fieldname not in cls.__annotations__:
- try:
- field = model._meta.get_field(fieldname)
- except FieldDoesNotExist:
- continue
-
- if isinstance(field, CounterCacheField):
- should_create_function = True
- attr_type = BigInt | None
- elif isinstance(field, ASNField):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, ColorField):
- should_create_function = True
- attr_type = List[str] | None
-
- create_attribute_and_function(cls, fieldname, attr_type, should_create_function)
-
- declared_filters = filterset.declared_filters
- for fieldname, field in declared_filters.items():
-
- should_create_function, attr_type = map_strawberry_type(field)
- if attr_type is None:
- raise NotImplementedError(f"GraphQL Filter field unknown: {fieldname}: {field}")
-
- create_attribute_and_function(cls, fieldname, attr_type, should_create_function)
-
- return cls
-
- return wrapper
+class NetBoxModelFilterMixin(
+ ChangeLogFilterMixin,
+ CustomFieldsFilterMixin,
+ JournalEntriesFilterMixin,
+ TagsFilterMixin,
+ BaseObjectTypeFilterMixin,
+):
+ pass
-@strawberry.input
-class BaseFilterMixin:
+@dataclass
+class NestedGroupModelFilterMixin(NetBoxModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ parent_id: ID | None = strawberry_django.filter_field()
- def filter_by_filterset(self, queryset, key):
- filterset = self.filterset(data={key: getattr(self, key)}, queryset=queryset)
- if not filterset.is_valid():
- # We could raise validation error but strawberry logs it all to the
- # console i.e. raise ValidationError(f"{k}: {v[0]}")
- return filterset.qs.none()
- return filterset.qs
+
+@dataclass
+class OrganizationalModelFilterMixin(
+ ChangeLogFilterMixin,
+ CustomFieldsFilterMixin,
+ TagsFilterMixin,
+ BaseObjectTypeFilterMixin,
+):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class PrimaryModelFilterMixin(NetBoxModelFilterMixin):
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ImageAttachmentFilterMixin(BaseFilterMixin):
+ images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class WeightFilterMixin(BaseFilterMixin):
+ weight: FilterLookup[float] | None = strawberry_django.filter_field()
+ weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class SyncedDataFilterMixin(BaseFilterMixin):
+ data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ data_source_id: FilterLookup[int] | None = strawberry_django.filter_field()
+ data_file: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
+ data_path: FilterLookup[str] | None = strawberry_django.filter_field()
+ auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class DistanceFilterMixin(BaseFilterMixin):
+ distance: FilterLookup[float] | None = strawberry_django.filter_field()
+ distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py
index a7609c9d2..c840e769c 100644
--- a/netbox/netbox/graphql/schema.py
+++ b/netbox/netbox/graphql/schema.py
@@ -1,7 +1,7 @@
import strawberry
from django.conf import settings
from strawberry_django.optimizer import DjangoOptimizerExtension
-from strawberry.extensions import MaxAliasesLimiter
+from strawberry.extensions import MaxAliasesLimiter # , SchemaExtension
from strawberry.schema.config import StrawberryConfig
from circuits.graphql.schema import CircuitsQuery
diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py
index a4fc99080..653462630 100644
--- a/netbox/netbox/graphql/types.py
+++ b/netbox/netbox/graphql/types.py
@@ -8,6 +8,7 @@ from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMi
__all__ = (
'BaseObjectType',
+ 'ContentTypeType',
'ObjectType',
'OrganizationalObjectType',
'NetBoxObjectType',
@@ -83,6 +84,7 @@ class NetBoxObjectType(
@strawberry_django.type(
ContentType,
fields=['id', 'app_label', 'model'],
+ pagination=True
)
class ContentTypeType:
pass
@@ -91,6 +93,7 @@ class ContentTypeType:
@strawberry_django.type(
ObjectType_,
fields=['id', 'app_label', 'model'],
+ pagination=True
)
class ObjectTypeType:
pass
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index d53f67803..3a9cb0f78 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -2,6 +2,7 @@ from contextlib import ExitStack
import logging
import uuid
+import warnings
from django.conf import settings
from django.contrib import auth, messages
@@ -37,7 +38,10 @@ class CoreMiddleware:
# Apply all registered request processors
with ExitStack() as stack:
for request_processor in registry['request_processors']:
- stack.enter_context(request_processor(request))
+ try:
+ stack.enter_context(request_processor(request))
+ except Exception as e:
+ warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
response = self.get_response(request)
# Check if language cookie should be renewed
@@ -99,18 +103,23 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
"""
Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
"""
+ async_capable = False
force_logout_if_no_header = False
+ def __init__(self, get_response):
+ if get_response is None:
+ raise ValueError("get_response must be provided.")
+ self.get_response = get_response
+
@property
def header(self):
return settings.REMOTE_AUTH_HEADER
- def process_request(self, request):
- logger = logging.getLogger(
- 'netbox.authentication.RemoteUserMiddleware')
+ def __call__(self, request):
+ logger = logging.getLogger('netbox.authentication.RemoteUserMiddleware')
# Bypass middleware if remote authentication is not enabled
if not settings.REMOTE_AUTH_ENABLED:
- return
+ return self.get_response(request)
# AuthenticationMiddleware is required so that request.user exists.
if not hasattr(request, 'user'):
raise ImproperlyConfigured(
@@ -127,13 +136,13 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
# AnonymousUser by the AuthenticationMiddleware).
if self.force_logout_if_no_header and request.user.is_authenticated:
self._remove_invalid_user(request)
- return
+ return self.get_response(request)
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.get_username() == self.clean_username(username, request):
- return
+ return self.get_response(request)
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
@@ -163,6 +172,8 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
request.user = user
auth.login(request, user)
+ return self.get_response(request)
+
def _get_groups(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index ca79d5e7e..f07f75736 100644
--- a/netbox/netbox/models/__init__.py
+++ b/netbox/netbox/models/__init__.py
@@ -151,6 +151,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
max_length=200,
blank=True
)
+ comments = models.TextField(
+ verbose_name=_('comments'),
+ blank=True
+ )
objects = TreeManager()
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index b58d232cd..25f23c9d3 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -5,6 +5,7 @@ from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import ValidationError
from django.db import models
+from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
@@ -19,7 +20,6 @@ from netbox.registry import registry
from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.serialization import serialize_object
-from utilities.views import register_model_view
__all__ = (
'BookmarksMixin',
@@ -360,7 +360,7 @@ class ImageAttachmentsMixin(models.Model):
class ContactsMixin(models.Model):
"""
- Enables the assignments of Contacts (via ContactAssignment).
+ Enables the assignment of Contacts to a model (via ContactAssignment).
"""
contacts = GenericRelation(
to='tenancy.ContactAssignment',
@@ -371,6 +371,27 @@ class ContactsMixin(models.Model):
class Meta:
abstract = True
+ def get_contacts(self, inherited=True):
+ """
+ Return a `QuerySet` matching all contacts assigned to this object.
+
+ Args:
+ inherited: If `True`, inherited contacts from parent objects are included.
+ """
+ from tenancy.models import ContactAssignment
+ from . import NestedGroupModel
+
+ filter = Q(
+ object_type=ObjectType.objects.get_for_model(self),
+ object_id__in=(
+ self.get_ancestors(include_self=True)
+ if (isinstance(self, NestedGroupModel) and inherited)
+ else [self.pk]
+ ),
+ )
+
+ return ContactAssignment.objects.filter(filter)
+
class BookmarksMixin(models.Model):
"""
@@ -442,7 +463,8 @@ class TagsMixin(models.Model):
which is a `TaggableManager` instance.
"""
tags = TaggableManager(
- through='extras.TaggedItem'
+ through='extras.TaggedItem',
+ ordering=('weight', 'name'),
)
class Meta:
@@ -625,6 +647,8 @@ def register_models(*models):
register_model() should be called for each relevant model under the ready() of an app's AppConfig class.
"""
+ from utilities.views import register_model_view
+
for model in models:
app_label, model_name = model._meta.label_lower.split('.')
@@ -645,6 +669,10 @@ def register_models(*models):
)
# Register applicable feature views for the model
+ if issubclass(model, ContactsMixin):
+ register_model_view(model, 'contacts', kwargs={'model': model})(
+ 'netbox.views.generic.ObjectContactsView'
+ )
if issubclass(model, JournalingMixin):
register_model_view(model, 'journal', kwargs={'model': model})(
'netbox.views.generic.ObjectJournalView'
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 9148caa8e..982d40829 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -14,9 +14,9 @@ ORGANIZATION_MENU = Menu(
MenuGroup(
label=_('Sites'),
items=(
- get_model_item('dcim', 'site', _('Sites')),
get_model_item('dcim', 'region', _('Regions')),
get_model_item('dcim', 'sitegroup', _('Site Groups')),
+ get_model_item('dcim', 'site', _('Sites')),
get_model_item('dcim', 'location', _('Locations')),
),
),
@@ -85,6 +85,7 @@ DEVICES_MENU = Menu(
items=(
get_model_item('dcim', 'devicetype', _('Device Types')),
get_model_item('dcim', 'moduletype', _('Module Types')),
+ get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
),
),
@@ -348,6 +349,7 @@ CUSTOMIZATION_MENU = Menu(
get_model_item('extras', 'customlink', _('Custom Links')),
get_model_item('extras', 'exporttemplate', _('Export Templates')),
get_model_item('extras', 'savedfilter', _('Saved Filters')),
+ get_model_item('extras', 'tableconfig', _('Table Configs'), actions=()),
get_model_item('extras', 'tag', 'Tags'),
get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
),
diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py
index 69881a251..b7bb0ef9f 100644
--- a/netbox/netbox/plugins/__init__.py
+++ b/netbox/netbox/plugins/__init__.py
@@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
from packaging import version
+from core.exceptions import IncompatiblePluginError
from netbox.registry import registry
from netbox.search import register_search
from netbox.utils import register_data_backend
@@ -16,6 +17,7 @@ from .utils import *
# Initialize plugin registry
registry['plugins'].update({
+ 'installed': [],
'graphql_schemas': [],
'menus': [],
'menu_items': {},
@@ -47,6 +49,7 @@ class PluginConfig(AppConfig):
author_email = ''
description = ''
version = ''
+ release_track = ''
# Root URL path under /plugins. If not set, the plugin's label will be used.
base_url = None
@@ -138,14 +141,14 @@ class PluginConfig(AppConfig):
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:
- raise ImproperlyConfigured(
+ raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
- raise ImproperlyConfigured(
+ raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
)
diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py
index 10cbc7f24..1cf40cc4a 100644
--- a/netbox/netbox/plugins/registration.py
+++ b/netbox/netbox/plugins/registration.py
@@ -1,7 +1,7 @@
import inspect
-import warnings
from django.utils.translation import gettext_lazy as _
+
from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
@@ -35,16 +35,8 @@ def register_template_extensions(class_list):
)
if template_extension.models:
- # Registration for multiple models
+ # Registration for specific models
models = template_extension.models
- elif template_extension.model:
- # Registration for a single model (deprecated)
- warnings.warn(
- "PluginTemplateExtension.model is deprecated and will be removed in a future release. Use "
- "'models' instead.",
- DeprecationWarning
- )
- models = [template_extension.model]
else:
# Global registration (no specific models)
models = [None]
diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py
index 4ea90b4db..586391d4f 100644
--- a/netbox/netbox/plugins/templates.py
+++ b/netbox/netbox/plugins/templates.py
@@ -11,8 +11,14 @@ class PluginTemplateExtension:
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 '.'. render() provides the following context data:
+ The `models` attribute on the class defines the which specific model detail pages this class renders content
+ for. It should be defined as a list of strings in the following form:
+
+ models = ['.', '.']
+
+ If `models` is left as None, the extension will render for _all_ models.
+
+ The `render()` method provides the following context data:
* object - The object being viewed (object views only)
* model - The type of object being viewed (list views only)
@@ -21,7 +27,6 @@ class PluginTemplateExtension:
* config - Plugin-specific configuration parameters
"""
models = None
- model = None # Deprecated; use `models` instead
def __init__(self, context):
self.context = context
@@ -42,6 +47,13 @@ class PluginTemplateExtension:
# Global methods
#
+ def head(self):
+ """
+ HTML returned by this method will be inserted in the page's `` block. This may be useful e.g. for
+ including additional Javascript or CSS resources.
+ """
+ raise NotImplementedError
+
def navbar(self):
"""
Content that will be rendered inside the top navigation menu. Content should be returned as an HTML
diff --git a/netbox/netbox/plugins/urls.py b/netbox/netbox/plugins/urls.py
index 7a9f30c7e..791c1d7b5 100644
--- a/netbox/netbox/plugins/urls.py
+++ b/netbox/netbox/plugins/urls.py
@@ -1,11 +1,11 @@
from importlib import import_module
from django.apps import apps
-from django.conf import settings
from django.conf.urls import include
from django.urls import path
from django.utils.module_loading import import_string, module_has_submodule
+from netbox.registry import registry
from . import views
plugin_patterns = []
@@ -15,7 +15,7 @@ plugin_api_patterns = [
]
# Register base/API URL patterns for each plugin
-for plugin_path in settings.PLUGINS:
+for plugin_path in registry['plugins']['installed']:
plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
diff --git a/netbox/netbox/plugins/utils.py b/netbox/netbox/plugins/utils.py
index c260f156d..886292274 100644
--- a/netbox/netbox/plugins/utils.py
+++ b/netbox/netbox/plugins/utils.py
@@ -2,6 +2,8 @@ from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
+from netbox.registry import registry
+
__all__ = (
'get_installed_plugins',
'get_plugin_config',
@@ -13,10 +15,13 @@ def get_installed_plugins():
Return a dictionary mapping the names of installed plugins to their versions.
"""
plugins = {}
- for plugin_name in settings.PLUGINS:
+ for plugin_name in registry['plugins']['installed']:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
- plugins[plugin_name] = getattr(plugin_config, 'version', None)
+ if plugin_config.release_track:
+ plugins[plugin_name] = f'{plugin_config.version}-{plugin_config.release_track}'
+ else:
+ plugins[plugin_name] = plugin_config.version or None
return dict(sorted(plugins.items()))
diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py
index 6a10f2e2c..feee78e82 100644
--- a/netbox/netbox/plugins/views.py
+++ b/netbox/netbox/plugins/views.py
@@ -1,7 +1,6 @@
from collections import OrderedDict
from django.apps import apps
-from django.conf import settings
from django.urls.exceptions import NoReverseMatch
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
@@ -9,6 +8,8 @@ from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
+from netbox.registry import registry
+
@extend_schema(exclude=True)
class InstalledPluginsAPIView(APIView):
@@ -30,11 +31,15 @@ class InstalledPluginsAPIView(APIView):
'author': plugin_app_config.author,
'author_email': plugin_app_config.author_email,
'description': plugin_app_config.description,
- 'version': plugin_app_config.version
+ 'version': plugin_app_config.version,
+ 'release_track': plugin_app_config.release_track,
}
def get(self, request, format=None):
- return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
+ return Response([
+ self._get_plugin_data(apps.get_app_config(plugin))
+ for plugin in registry['plugins']['installed']
+ ])
@extend_schema(exclude=True)
@@ -64,7 +69,7 @@ class PluginsAPIRootView(APIView):
def get(self, request, format=None):
entries = []
- for plugin in settings.PLUGINS:
+ for plugin in registry['plugins']['installed']:
app_config = apps.get_app_config(plugin)
entry = self._get_plugin_entry(plugin, app_config, request, format)
if entry is not None:
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index dbbac6ec4..239b5978f 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -9,11 +9,15 @@ import warnings
from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator
+from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
+from core.exceptions import IncompatiblePluginError
from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
+from netbox.registry import registry
+import storages.utils # type: ignore
from utilities.release import load_release_data
from utilities.string import trailing_slash
@@ -49,14 +53,18 @@ except ModuleNotFoundError as e:
)
raise
-# Check for missing required configuration parameters
-for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'):
+# Check for missing/conflicting required configuration parameters
+for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
if not hasattr(configuration, parameter):
raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.")
+if not hasattr(configuration, 'DATABASE') and not hasattr(configuration, 'DATABASES'):
+ raise ImproperlyConfigured("The database configuration must be defined using DATABASE or DATABASES.")
+elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'):
+ raise ImproperlyConfigured("DATABASE and DATABASES may not be set together. The use of DATABASES is encouraged.")
# Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', [])
-ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True)
+ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False)
ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
{
@@ -80,7 +88,9 @@ CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
-DATABASE = getattr(configuration, 'DATABASE') # Required
+DATABASE = getattr(configuration, 'DATABASE', None) # Legacy DB definition
+DATABASE_ROUTERS = getattr(configuration, 'DATABASE_ROUTERS', [])
+DATABASES = getattr(configuration, 'DATABASES', {'default': DATABASE})
DEBUG = getattr(configuration, 'DEBUG', False)
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
@@ -115,7 +125,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
-HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
+HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
@@ -125,11 +135,14 @@ LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
+LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
+PLUGINS_CATALOG_CONFIG = getattr(configuration, 'PLUGINS_CATALOG_CONFIG', {})
+PROXY_ROUTERS = getattr(configuration, 'PROXY_ROUTERS', ['utilities.proxy.DefaultProxyRouter'])
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
REDIS = getattr(configuration, 'REDIS') # Required
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
@@ -173,7 +186,8 @@ SESSION_COOKIE_PATH = CSRF_COOKIE_PATH
SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False)
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
-STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
+STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None)
+STORAGES = getattr(configuration, 'STORAGES', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
DISK_BASE_UNIT = getattr(configuration, 'DISK_BASE_UNIT', 1000)
@@ -206,83 +220,92 @@ if RELEASE_CHECK_URL:
"RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox"
)
+# Validate configured proxy routers
+for path in PROXY_ROUTERS:
+ if type(path) is str:
+ try:
+ import_string(path)
+ except ImportError:
+ raise ImproperlyConfigured(f"Invalid path in PROXY_ROUTERS: {path}")
+
#
# Database
#
-# Set the database engine
-if 'ENGINE' not in DATABASE:
- if METRICS_ENABLED:
- DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'})
- else:
- DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
+# Verify that a default database has been configured
+if 'default' not in DATABASES:
+ raise ImproperlyConfigured("No default database has been configured.")
-# Define the DATABASES setting for Django
-DATABASES = {
- 'default': DATABASE,
-}
+# Set the database engine
+if 'ENGINE' not in DATABASES['default']:
+ DATABASES['default'].update({
+ 'ENGINE': 'django_prometheus.db.backends.postgresql' if METRICS_ENABLED else 'django.db.backends.postgresql'
+ })
#
# Storage backend
#
+if STORAGE_BACKEND is not None:
+ if not STORAGES:
+ raise ImproperlyConfigured(
+ "STORAGE_BACKEND and STORAGES are both set, remove the deprecated STORAGE_BACKEND setting."
+ )
+ else:
+ warnings.warn(
+ "STORAGE_BACKEND is deprecated, use the new STORAGES setting instead."
+ )
+
+if STORAGE_CONFIG is not None:
+ warnings.warn(
+ "STORAGE_CONFIG is deprecated, use the new STORAGES setting instead."
+ )
+
# Default STORAGES for Django
-STORAGES = {
+DEFAULT_STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
+ "scripts": {
+ "BACKEND": "extras.storage.ScriptFileSystemStorage",
+ },
}
+STORAGES = DEFAULT_STORAGES | STORAGES
+# TODO: This code is deprecated and needs to be removed in the future
if STORAGE_BACKEND is not None:
STORAGES['default']['BACKEND'] = STORAGE_BACKEND
- # django-storages
- if STORAGE_BACKEND.startswith('storages.'):
- try:
- import storages.utils # type: ignore
- except ModuleNotFoundError as e:
- if getattr(e, 'name') == 'storages':
- raise ImproperlyConfigured(
- f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be "
- f"installed by running 'pip install django-storages'."
- )
- raise e
+# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
+if STORAGE_CONFIG is not None:
+ def _setting(name, default=None):
+ if name in STORAGE_CONFIG:
+ return STORAGE_CONFIG[name]
+ return globals().get(name, default)
+ storages.utils.setting = _setting
- # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
- def _setting(name, default=None):
- if name in STORAGE_CONFIG:
- return STORAGE_CONFIG[name]
- return globals().get(name, default)
- storages.utils.setting = _setting
-
- # django-storage-swift
- elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
- try:
- import swift.utils # noqa: F401
- except ModuleNotFoundError as e:
- if getattr(e, 'name') == 'swift':
- raise ImproperlyConfigured(
- f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
- "It can be installed by running 'pip install django-storage-swift'."
- )
- raise e
-
- # Load all SWIFT_* settings from the user configuration
- for param, value in STORAGE_CONFIG.items():
- if param.startswith('SWIFT_'):
- globals()[param] = value
-
-if STORAGE_CONFIG and STORAGE_BACKEND is None:
- warnings.warn(
- "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
- "ignored."
- )
+# django-storage-swift
+if STORAGE_BACKEND == 'swift.storage.SwiftStorage':
+ try:
+ import swift.utils # noqa: F401
+ except ModuleNotFoundError as e:
+ if getattr(e, 'name') == 'swift':
+ raise ImproperlyConfigured(
+ f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
+ "It can be installed by running 'pip install django-storage-swift'."
+ )
+ raise e
+ # Load all SWIFT_* settings from the user configuration
+ for param, value in STORAGE_CONFIG.items():
+ if param.startswith('SWIFT_'):
+ globals()[param] = value
+# TODO: End of deprecated code
#
# Redis
@@ -582,6 +605,7 @@ if SENTRY_ENABLED:
sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=SENTRY_SEND_DEFAULT_PII,
+ # TODO: Support proxy routing
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
)
@@ -781,7 +805,7 @@ LOCALE_PATHS = (
STRAWBERRY_DJANGO = {
"DEFAULT_PK_FIELD_NAME": "id",
"TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True,
- "USE_DEPRECATED_FILTERS": True,
+ "PAGINATION_DEFAULT_LIMIT": 100,
}
#
@@ -816,6 +840,18 @@ for plugin_name in PLUGINS:
f"__init__.py file and point to the PluginConfig subclass."
)
+ # Validate version compatibility and user-provided configuration settings and assign defaults
+ if plugin_name not in PLUGINS_CONFIG:
+ PLUGINS_CONFIG[plugin_name] = {}
+ try:
+ plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version)
+ except IncompatiblePluginError as e:
+ warnings.warn(f'Unable to load plugin {plugin_name}: {e}')
+ continue
+
+ # Register the plugin as installed successfully
+ registry['plugins']['installed'].append(plugin_name)
+
plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore
# Gather additional apps to load alongside this plugin
@@ -845,11 +881,6 @@ for plugin_name in PLUGINS:
sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS))))
INSTALLED_APPS = list(sorted_apps)
- # Validate user-provided configuration settings and assign defaults
- if plugin_name not in PLUGINS_CONFIG:
- PLUGINS_CONFIG[plugin_name] = {}
- plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version)
-
# Add middleware
plugin_middleware = plugin_config.middleware
if plugin_middleware and type(plugin_middleware) in (list, tuple):
@@ -871,6 +902,7 @@ for plugin_name in PLUGINS:
else:
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
+
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *
diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py
deleted file mode 100644
index e6b946403..000000000
--- a/netbox/netbox/staging.py
+++ /dev/null
@@ -1,148 +0,0 @@
-import logging
-
-from django.contrib.contenttypes.models import ContentType
-from django.db import transaction
-from django.db.models.signals import m2m_changed, pre_delete, post_save
-
-from extras.choices import ChangeActionChoices
-from extras.models import StagedChange
-from utilities.serialization import serialize_object
-
-logger = logging.getLogger('netbox.staging')
-
-
-class checkout:
- """
- Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band
- (as Change instances) for application at a later time, without modifying the production
- database.
-
- branch = Branch.objects.create(name='my-branch')
- with checkout(branch):
- # All changes made herein will be rolled back and stored for later
-
- Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks,
- and restores its original value upon exit.
- """
- def __init__(self, branch):
- self.branch = branch
- self.queue = {}
-
- def __enter__(self):
-
- # Disable autocommit to effect a new transaction
- logger.debug(f"Entering transaction for {self.branch}")
- self._autocommit = transaction.get_autocommit()
- transaction.set_autocommit(False)
-
- # Apply any existing Changes assigned to this Branch
- staged_changes = self.branch.staged_changes.all()
- if change_count := staged_changes.count():
- logger.debug(f"Applying {change_count} pre-staged changes...")
- for change in staged_changes:
- change.apply()
- else:
- logger.debug("No pre-staged changes found")
-
- # Connect signal handlers
- logger.debug("Connecting signal handlers")
- post_save.connect(self.post_save_handler)
- m2m_changed.connect(self.post_save_handler)
- pre_delete.connect(self.pre_delete_handler)
-
- def __exit__(self, exc_type, exc_val, exc_tb):
-
- # Disconnect signal handlers
- logger.debug("Disconnecting signal handlers")
- post_save.disconnect(self.post_save_handler)
- m2m_changed.disconnect(self.post_save_handler)
- pre_delete.disconnect(self.pre_delete_handler)
-
- # Roll back the transaction to return the database to its original state
- logger.debug("Rolling back database transaction")
- transaction.rollback()
- logger.debug(f"Restoring autocommit state ({self._autocommit})")
- transaction.set_autocommit(self._autocommit)
-
- # Process queued changes
- self.process_queue()
-
- #
- # Queuing
- #
-
- @staticmethod
- def get_key_for_instance(instance):
- return ContentType.objects.get_for_model(instance), instance.pk
-
- def process_queue(self):
- """
- Create Change instances for all actions stored in the queue.
- """
- if not self.queue:
- logger.debug("No queued changes; aborting")
- return
- logger.debug(f"Processing {len(self.queue)} queued changes")
-
- # Iterate through the in-memory queue, creating Change instances
- changes = []
- for key, change in self.queue.items():
- logger.debug(f' {key}: {change}')
- object_type, pk = key
- action, data = change
-
- changes.append(StagedChange(
- branch=self.branch,
- action=action,
- object_type=object_type,
- object_id=pk,
- data=data
- ))
-
- # Save all Change instances to the database
- StagedChange.objects.bulk_create(changes)
-
- #
- # Signal handlers
- #
-
- def post_save_handler(self, sender, instance, **kwargs):
- """
- Hooks to the post_save signal when a branch is active to queue create and update actions.
- """
- key = self.get_key_for_instance(instance)
- object_type = instance._meta.verbose_name
-
- # Creating a new object
- if kwargs.get('created'):
- logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data)
- return
-
- # Ignore pre_* many-to-many actions
- if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'):
- return
-
- # Object has already been created/updated in the queue; update its queued representation
- if key in self.queue:
- logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (self.queue[key][0], data)
- return
-
- # Modifying an existing object for the first time
- logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})")
- data = serialize_object(instance, resolve_tags=False)
- self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data)
-
- def pre_delete_handler(self, sender, instance, **kwargs):
- """
- Hooks to the pre_delete signal when a branch is active to queue delete actions.
- """
- key = self.get_key_for_instance(instance)
- object_type = instance._meta.verbose_name
-
- # Delete an existing object
- logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})")
- self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None)
diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py
index cf6e1f133..3d36ec8f8 100644
--- a/netbox/netbox/tables/columns.py
+++ b/netbox/netbox/tables/columns.py
@@ -35,6 +35,7 @@ __all__ = (
'ContentTypesColumn',
'CustomFieldColumn',
'CustomLinkColumn',
+ 'DictColumn',
'DistanceColumn',
'DurationColumn',
'LinkedCountColumn',
@@ -259,11 +260,15 @@ class ActionsColumn(tables.Column):
return ''
def render(self, record, table, **kwargs):
- # Skip dummy records (e.g. available VLANs) or those with no actions
- if not getattr(record, 'pk', None) or not (self.actions or self.extra_buttons):
+ model = table.Meta.model
+
+ # Skip if no actions or extra buttons are defined
+ if not (self.actions or self.extra_buttons):
+ return ''
+ # Skip dummy records (e.g. available VLANs or IP ranges replacing individual IPs)
+ if type(record) is not model or not getattr(record, 'pk', None):
return ''
- model = table.Meta.model
if request := getattr(table, 'context', {}).get('request'):
return_url = request.GET.get('return_url', request.get_full_path())
url_appendix = f'?return_url={quote(return_url)}'
@@ -707,3 +712,14 @@ class DistanceColumn(TemplateColumn):
def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
super().__init__(template_code=template_code, order_by=order_by, **kwargs)
+
+
+class DictColumn(tables.Column):
+ """
+ Render a dictionary of data in a simple key: value format, one pair per line.
+ """
+ def render(self, value):
+ output = ' '.join([
+ f'{escape(k)}: {escape(v)}' for k, v in value.items()
+ ])
+ return mark_safe(output)
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
index 2d2c430aa..6f6b30af2 100644
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -1,9 +1,9 @@
from copy import deepcopy
from functools import cached_property
+from urllib.parse import urlencode
import django_tables2 as tables
from django.conf import settings
-from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
@@ -20,8 +20,8 @@ from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
-from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.html import highlight
+from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.string import title
from utilities.views import get_viewname
from .template_code import *
@@ -58,40 +58,6 @@ class BaseTable(tables.Table):
if self.empty_text is None:
self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)
- # Determine the table columns to display by checking the following:
- # 1. User's configuration for the table
- # 2. Meta.default_columns
- # 3. Meta.fields
- selected_columns = None
- if user is not None and not isinstance(user, AnonymousUser):
- selected_columns = user.config.get(f"tables.{self.name}.columns")
- elif isinstance(user, AnonymousUser) and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
- selected_columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
- if not selected_columns:
- selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
-
- # Hide non-selected columns which are not exempt
- for column in self.columns:
- if column.name not in [*selected_columns, *self.exempt_columns]:
- self.columns.hide(column.name)
-
- # Rearrange the sequence to list selected columns first, followed by all remaining columns
- # TODO: There's probably a more clever way to accomplish this
- self.sequence = [
- *[c for c in selected_columns if c in self.columns.names()],
- *[c for c in self.columns.names() if c not in selected_columns]
- ]
-
- # PK column should always come first
- if 'pk' in self.sequence:
- self.sequence.remove('pk')
- self.sequence.insert(0, 'pk')
-
- # Actions column should always come last
- if 'actions' in self.sequence:
- self.sequence.remove('actions')
- self.sequence.append('actions')
-
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if isinstance(self.data, TableQuerysetData):
@@ -147,25 +113,66 @@ class BaseTable(tables.Table):
self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk'))
return self._objects_count
+ def _set_columns(self, selected_columns):
+ """
+ Update the table sequence to display only the named columns and any exempt columns.
+ """
+ # Hide non-selected columns which are not exempt
+ for column in self.columns:
+ if column.name not in [*selected_columns, *self.exempt_columns]:
+ self.columns.hide(column.name)
+
+ # Rearrange the sequence to list selected columns first, followed by all remaining columns
+ # TODO: There's probably a more clever way to accomplish this
+ self.sequence = [
+ *[c for c in selected_columns if c in self.columns.names()],
+ *[c for c in self.columns.names() if c not in selected_columns]
+ ]
+
+ # PK column should always come first
+ if 'pk' in self.sequence:
+ self.sequence.remove('pk')
+ self.sequence.insert(0, 'pk')
+
+ # Actions column should always come last
+ if 'actions' in self.sequence:
+ self.sequence.remove('actions')
+ self.sequence.append('actions')
+
def configure(self, request):
"""
Configure the table for a specific request context. This performs pagination and records
- the user's preferred ordering logic.
+ the user's preferred columns & ordering logic.
"""
- # Save ordering preference
- if request.user.is_authenticated:
- if self.prefixed_order_by_field in request.GET:
- if request.GET[self.prefixed_order_by_field]:
- # If an ordering has been specified as a query parameter, save it as the
- # user's preferred ordering for this table.
- ordering = request.GET.getlist(self.prefixed_order_by_field)
- request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
- else:
- # If the ordering has been set to none (empty), clear any existing preference.
- request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
- elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
- # If no ordering has been specified, set the preferred ordering (if any).
- self.order_by = ordering
+ columns = None
+ ordering = None
+
+ if self.prefixed_order_by_field in request.GET:
+ if request.GET[self.prefixed_order_by_field]:
+ # If an ordering has been specified as a query parameter, save it as the
+ # user's preferred ordering for this table.
+ ordering = request.GET.getlist(self.prefixed_order_by_field)
+ request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
+ else:
+ # If the ordering has been set to none (empty), clear any existing preference.
+ request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
+
+ # If the user has a saved preference, apply it
+ if request.user.is_authenticated and (userconfig := request.user.config):
+ if columns is None:
+ columns = userconfig.get(f"tables.{self.name}.columns")
+ if ordering is None:
+ ordering = userconfig.get(f"tables.{self.name}.ordering")
+
+ # Fall back to the default columns & ordering
+ if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
+ columns = settings.DEFAULT_USER_PREFERENCES.get('tables', {}).get(self.name, {}).get('columns')
+ if columns is None:
+ columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
+
+ self._set_columns(columns)
+ if ordering is not None:
+ self.order_by = ordering
# Paginate the table results
paginate = {
@@ -174,6 +181,25 @@ class BaseTable(tables.Table):
}
tables.RequestConfig(request, paginate).configure(self)
+ @property
+ def configuration(self):
+ config = {
+ 'columns': ','.join([c[0] for c in self.selected_columns]),
+ }
+ if self.order_by:
+ config['ordering'] = self.order_by
+ return config
+
+ @property
+ def config_params(self):
+ if not (model := getattr(self.Meta, 'model', None)):
+ return None
+ return urlencode({
+ 'object_type': ObjectType.objects.get_for_model(model).pk,
+ 'table': self.name,
+ **self.configuration,
+ })
+
class NetBoxTable(BaseTable):
"""
diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py
index e9a6b9da1..e962594d4 100644
--- a/netbox/netbox/tests/dummy_plugin/template_content.py
+++ b/netbox/netbox/tests/dummy_plugin/template_content.py
@@ -3,6 +3,9 @@ from netbox.plugins.templates import PluginTemplateExtension
class GlobalContent(PluginTemplateExtension):
+ def head(self):
+ return ""
+
def navbar(self):
return "GLOBAL CONTENT - NAVBAR"
diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py
index b04d42d24..ca231526f 100644
--- a/netbox/netbox/tests/test_graphql.py
+++ b/netbox/netbox/tests/test_graphql.py
@@ -99,8 +99,8 @@ class GraphQLAPITestCase(APITestCase):
# Test OR logic
query = """{
location_list( filters: {
- status: \"""" + LocationStatusChoices.STATUS_PLANNED + """\",
- OR: {status: \"""" + LocationStatusChoices.STATUS_STAGING + """\"}
+ status: STATUS_PLANNED,
+ OR: {status: STATUS_STAGING}
}) {
id site {id}
}
diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py
deleted file mode 100644
index 0a73b2987..000000000
--- a/netbox/netbox/tests/test_staging.py
+++ /dev/null
@@ -1,216 +0,0 @@
-from django.db.models.signals import post_save
-from django.test import TransactionTestCase
-
-from circuits.models import Provider, Circuit, CircuitType
-from extras.choices import ChangeActionChoices
-from extras.models import Branch, StagedChange, Tag
-from ipam.models import ASN, RIR
-from netbox.search.backends import search_backend
-from netbox.staging import checkout
-from utilities.testing import create_tags
-
-
-class StagingTestCase(TransactionTestCase):
-
- def setUp(self):
- # Disconnect search backend to avoid issues with cached ObjectTypes being deleted
- # from the database upon transaction rollback
- post_save.disconnect(search_backend.caching_handler)
-
- create_tags('Alpha', 'Bravo', 'Charlie')
-
- rir = RIR.objects.create(name='RIR 1', slug='rir-1')
- asns = (
- ASN(asn=65001, rir=rir),
- ASN(asn=65002, rir=rir),
- ASN(asn=65003, rir=rir),
- )
- ASN.objects.bulk_create(asns)
-
- providers = (
- Provider(name='Provider A', slug='provider-a'),
- Provider(name='Provider B', slug='provider-b'),
- Provider(name='Provider C', slug='provider-c'),
- )
- Provider.objects.bulk_create(providers)
-
- circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
-
- Circuit.objects.bulk_create((
- Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type),
- Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type),
- Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type),
- Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type),
- Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type),
- ))
-
- def test_object_creation(self):
- branch = Branch.objects.create(name='Branch 1')
- tags = Tag.objects.all()
- asns = ASN.objects.all()
-
- with checkout(branch):
- provider = Provider.objects.create(name='Provider D', slug='provider-d')
- provider.asns.set(asns)
- circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first())
- circuit.tags.set(tags)
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 4)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 10)
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(StagedChange.objects.count(), 5)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 4)
- self.assertEqual(Circuit.objects.count(), 10)
- provider = Provider.objects.get(name='Provider D')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- circuit = Circuit.objects.get(cid='Circuit D1')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 4)
- self.assertEqual(Circuit.objects.count(), 10)
- provider = Provider.objects.get(name='Provider D')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- circuit = Circuit.objects.get(cid='Circuit D1')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_object_modification(self):
- branch = Branch.objects.create(name='Branch 1')
- tags = Tag.objects.all()
- asns = ASN.objects.all()
-
- with checkout(branch):
- provider = Provider.objects.get(name='Provider A')
- provider.name = 'Provider X'
- provider.save()
- provider.asns.set(asns)
- circuit = Circuit.objects.get(cid='Circuit A1')
- circuit.cid = 'Circuit X'
- circuit.save()
- circuit.tags.set(tags)
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), [])
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit A1')
- self.assertListEqual(list(circuit.tags.all()), [])
- self.assertEqual(StagedChange.objects.count(), 5)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X')
- provider = Provider.objects.get(pk=provider.pk)
- self.assertListEqual(list(provider.asns.all()), list(asns))
- self.assertEqual(Circuit.objects.count(), 9)
- circuit = Circuit.objects.get(pk=circuit.pk)
- self.assertEqual(circuit.cid, 'Circuit X')
- self.assertListEqual(list(circuit.tags.all()), list(tags))
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_object_deletion(self):
- branch = Branch.objects.create(name='Branch 1')
-
- with checkout(branch):
- provider = Provider.objects.get(name='Provider A')
- provider.circuits.all().delete()
- provider.delete()
-
- # Sanity-checking
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
-
- # Verify that changes have been rolled back after exiting the context
- self.assertEqual(Provider.objects.count(), 3)
- self.assertEqual(Circuit.objects.count(), 9)
- self.assertEqual(StagedChange.objects.count(), 4)
-
- # Verify that changes are replayed upon entering the context
- with checkout(branch):
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
-
- # Verify that changes are applied and deleted upon branch merge
- branch.merge()
- self.assertEqual(Provider.objects.count(), 2)
- self.assertEqual(Circuit.objects.count(), 6)
- self.assertEqual(StagedChange.objects.count(), 0)
-
- def test_exit_enter_context(self):
- branch = Branch.objects.create(name='Branch 1')
-
- with checkout(branch):
-
- # Create a new object
- provider = Provider.objects.create(name='Provider D', slug='provider-d')
- provider.save()
-
- # Check that a create Change was recorded
- self.assertEqual(StagedChange.objects.count(), 1)
- change = StagedChange.objects.first()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE)
- self.assertEqual(change.data['name'], provider.name)
-
- with checkout(branch):
-
- # Update the staged object
- provider = Provider.objects.get(name='Provider D')
- provider.comments = 'New comments'
- provider.save()
-
- # Check that a second Change object has been created for the object
- self.assertEqual(StagedChange.objects.count(), 2)
- change = StagedChange.objects.last()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE)
- self.assertEqual(change.data['name'], provider.name)
- self.assertEqual(change.data['comments'], provider.comments)
-
- with checkout(branch):
-
- # Delete the staged object
- provider = Provider.objects.get(name='Provider D')
- provider.delete()
-
- # Check that a third Change has recorded the object's deletion
- self.assertEqual(StagedChange.objects.count(), 3)
- change = StagedChange.objects.last()
- self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE)
- self.assertIsNone(change.data)
diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py
index cf34e2293..4fd23e84c 100644
--- a/netbox/netbox/views/generic/bulk_views.py
+++ b/netbox/netbox/views/generic/bulk_views.py
@@ -29,6 +29,8 @@ from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering
+from utilities.request import safe_for_redirect
+from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView
from .mixins import ActionsMixin, TableMixin
@@ -108,7 +110,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
request: The current request
"""
try:
- return template.render_to_response(self.queryset)
+ return template.render_to_response(queryset=self.queryset)
except Exception as e:
messages.error(
request,
@@ -120,7 +122,10 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Strip the `export` param and redirect user to the filtered objects list
query_params = request.GET.copy()
query_params.pop('export')
- return redirect(f'{request.path}?{query_params.urlencode()}')
+ redirect_url = f'{request.path}?{query_params.urlencode()}'
+ if safe_for_redirect(redirect_url):
+ return redirect(redirect_url)
+ return redirect(get_viewname(self.queryset.model, 'list'))
#
# Request handlers
@@ -191,6 +196,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
context = {
'model': model,
'table': table,
+ 'table_configs': get_table_configs(table, request.user),
'actions': actions,
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'prerequisite_model': get_prerequisite_model(self.queryset),
@@ -284,7 +290,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
logger.info(msg)
messages.success(request, msg)
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.path):
return redirect(request.path)
return redirect(self.get_return_url(request))
diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py
index 01c4b2862..9ad14a3d0 100644
--- a/netbox/netbox/views/generic/feature_views.py
+++ b/netbox/netbox/views/generic/feature_views.py
@@ -12,13 +12,19 @@ from core.tables import JobTable, ObjectChangeTable
from extras.forms import JournalEntryForm
from extras.models import JournalEntry
from extras.tables import JournalEntryTable
+from tenancy.models import ContactAssignment
+from tenancy.tables import ContactAssignmentTable
+from tenancy.filtersets import ContactAssignmentFilterSet
+from tenancy.forms import ContactAssignmentFilterForm
from utilities.permissions import get_permission_for_model
from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab
from .base import BaseMultiObjectView
+from .object_views import ObjectChildrenView
__all__ = (
'BulkSyncDataView',
'ObjectChangeLogView',
+ 'ObjectContactsView',
'ObjectJobsView',
'ObjectJournalView',
'ObjectSyncDataView',
@@ -244,3 +250,25 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
))
return redirect(self.get_return_url(request))
+
+
+class ObjectContactsView(ObjectChildrenView):
+ child_model = ContactAssignment
+ table = ContactAssignmentTable
+ filterset = ContactAssignmentFilterSet
+ filterset_form = ContactAssignmentFilterForm
+ template_name = 'tenancy/object_contacts.html'
+ tab = ViewTab(
+ label=_('Contacts'),
+ badge=lambda obj: obj.get_contacts().count(),
+ permission='tenancy.view_contactassignment',
+ weight=5000
+ )
+
+ def dispatch(self, request, *args, **kwargs):
+ model = kwargs.pop('model')
+ self.queryset = model.objects.all()
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_children(self, request, parent):
+ return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role')
diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py
index 25621b7bd..5f9f62120 100644
--- a/netbox/netbox/views/generic/mixins.py
+++ b/netbox/netbox/views/generic/mixins.py
@@ -1,3 +1,6 @@
+from django.shortcuts import get_object_or_404
+
+from extras.models import TableConfig
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from utilities.permissions import get_permission_for_model
@@ -47,6 +50,15 @@ class TableMixin:
request: The current request
bulk_actions: Render checkboxes for object selection
"""
+
+ # If a TableConfig has been specified, apply it & update the user's saved preference
+ if tableconfig_id := request.GET.get('tableconfig_id'):
+ tableconfig = get_object_or_404(TableConfig, pk=tableconfig_id)
+ if request.user.is_authenticated:
+ table = self.table.__name__
+ request.user.config.set(f'tables.{table}.columns', tableconfig.columns)
+ request.user.config.set(f'tables.{table}.ordering', tableconfig.ordering, commit=True)
+
table = self.table(data, user=request.user)
if 'pk' in table.base_columns and bulk_actions:
table.columns.show('pk')
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index fb554ca4f..0db73b7a6 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -20,6 +20,8 @@ from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields
+from utilities.request import safe_for_redirect
+from utilities.tables import get_table_configs
from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseObjectView
from .mixins import ActionsMixin, TableMixin
@@ -156,6 +158,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
'table': table,
'table_config': f'{table.name}_config',
+ 'table_configs': get_table_configs(table, request.user),
'filter_form': self.filterset_form(request.GET) if self.filterset_form else None,
'actions': actions,
'tab': self.tab,
@@ -315,6 +318,8 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
redirect_url += f"?{params.urlencode()}"
+ if not safe_for_redirect(redirect_url):
+ redirect_url = reverse('home')
return redirect(redirect_url)
@@ -581,7 +586,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
))
# Redirect user on success
- if '_addanother' in request.POST:
+ if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
else:
return redirect(self.get_return_url(request))
diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css
index 702520216..a7390f98f 100644
Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index f09e4637b..26d652ad0 100644
Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index 814f9f568..8f86f8ac7 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 88c6c8050..22171ec2c 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/netbox-graphiql/package.json b/netbox/project-static/netbox-graphiql/package.json
index 00218185a..be4f8911f 100644
--- a/netbox/project-static/netbox-graphiql/package.json
+++ b/netbox/project-static/netbox-graphiql/package.json
@@ -1,6 +1,6 @@
{
"name": "netbox-graphiql",
- "version": "4.2.0",
+ "version": "4.3.0",
"description": "NetBox GraphiQL Custom Front End",
"main": "dist/graphiql.js",
"license": "Apache-2.0",
diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json
index 935c82d90..9bef4ac73 100644
--- a/netbox/project-static/package.json
+++ b/netbox/project-static/package.json
@@ -1,6 +1,6 @@
{
"name": "netbox",
- "version": "4.2.8",
+ "version": "4.3.0",
"main": "dist/netbox.js",
"license": "Apache-2.0",
"private": true,
@@ -23,12 +23,12 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
- "@tabler/core": "1.0.0-beta21",
+ "@tabler/core": "1.2.0",
"bootstrap": "5.3.5",
"clipboard": "2.0.11",
"flatpickr": "4.6.13",
- "gridstack": "11.5.0",
- "htmx.org": "1.9.12",
+ "gridstack": "12.1.1",
+ "htmx.org": "2.0.4",
"query-string": "9.1.1",
"sass": "1.87.0",
"tom-select": "2.4.3",
@@ -41,7 +41,7 @@
"@types/node": "^22.3.0",
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
- "esbuild": "^0.24.2",
+ "esbuild": "^0.25.3",
"esbuild-sass-plugin": "^3.3.1",
"eslint": "<9.0",
"eslint-config-prettier": "^9.1.0",
diff --git a/netbox/project-static/src/forms/elements.ts b/netbox/project-static/src/forms/elements.ts
index e047ea738..a397feed6 100644
--- a/netbox/project-static/src/forms/elements.ts
+++ b/netbox/project-static/src/forms/elements.ts
@@ -1,6 +1,12 @@
import { getElements, scrollTo } from '../util';
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
+ // Automatically select all options in any