Merge branch 'feature' into 22486-webhook

This commit is contained in:
Arthur 2026-07-23 14:04:10 -07:00
commit b82016ce0d
101 changed files with 3292 additions and 502 deletions

View File

@ -6,6 +6,9 @@ NetBox's configuration file contains all the important parameters which control
The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one.
!!! note "Python package installations (experimental)"
An experimental Python package installation loads `$NETBOX_ROOT/conf/configuration.py` by default. `NETBOX_ROOT` defaults to `/opt/netbox`. Use `netbox setup --target <path>` to scaffold the local configuration, and keep configuration and mutable instance data outside the virtual environment and installed package. The setup target is not persisted; set `NETBOX_ROOT` for all commands and services when using a non-default path.
!!! info "Customizing the Configuration Module"
A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`.

View File

@ -39,7 +39,7 @@ API_TOKEN_PEPPERS = {
!!! warning "Peppers are sensitive"
Treat pepper values as extremely sensitive. Consider populating peppers from environment variables at initialization time rather than defining them in the configuration file, if feasible.
Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value.
Peppers must be at least 50 characters in length and should comprise a random string with a diverse character set. Consider using the Python script at `$INSTALL_ROOT/netbox/generate_secret_key.py` to generate a pepper value. For a Python package installation, run the virtual environment's `netbox secret-key` command instead.
It is recommended to start with a pepper ID of `1`. Additional peppers can be introduced later as needed to begin rotating token hashes.
@ -251,4 +251,4 @@ REDIS = {
This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes.
`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.
`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. For a Python package installation, run the virtual environment's `netbox secret-key` command instead. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.

View File

@ -6,7 +6,7 @@
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
A list of permitted URL schemes referenced when rendering links within NetBox. This list is also enforced when validating the value of URL custom fields. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable).
---

View File

@ -12,6 +12,20 @@ BASE_PATH = 'netbox/'
---
## BULK_UPDATE_CHUNK_SIZE
Default: `5000`
The maximum number of rows to affect in a single SQL `UPDATE` statement when NetBox performs a bulk update across many objects (for example, when recalculating cached counters or backfilling custom field data). On very large tables, an unbounded update spanning millions of rows can exceed the database's configured statement timeout; splitting the work into batches of at most this many rows bounds each statement while keeping the overall operation atomic.
Must be a positive integer, or `None` to disable chunking and issue each bulk update as a single unbounded statement.
```python
BULK_UPDATE_CHUNK_SIZE = 5000
```
---
## DATABASE_ROUTERS
Default: `[]` (empty list)

View File

@ -17,7 +17,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* Date & time: A date and time in ISO 8601 format (YYYY-MM-DD HH:MM:SS)
* URL: This will be presented as a link in the web UI
* URL: This will be presented as a link in the web UI. Values are restricted to the schemes permitted by [`ALLOWED_URL_SCHEMES`](../configuration/security.md#allowed_url_schemes). A value entered without a scheme (e.g. `example.com`) is assumed to use `https` and stored as an absolute URL (e.g. `https://example.com`).
* JSON: Arbitrary data stored in JSON format
* Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values

View File

@ -1,6 +1,6 @@
# NetBox Installation
# Install NetBox from a Release Archive or Git
This section of the documentation discusses installing and configuring the NetBox application itself.
This page covers the established release archive and Git installation methods. To install NetBox from the experimental Python package instead, follow the [separate package installation guide](3b-python-package.md).
## Install System Packages
@ -99,7 +99,7 @@ cd /opt/netbox/netbox/netbox/
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:
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following five are required for new installations:
* `ALLOWED_HOSTS`
* `API_TOKEN_PEPPERS`

View File

@ -0,0 +1,361 @@
# Install NetBox from the Python Package (Experimental)
!!! warning "Experimental in NetBox v4.7"
Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Use this workflow to evaluate the packaged installation, test upgrades and rollback procedures, and provide feedback.
The established [release archive and Git installation methods](3-netbox.md) remain supported and are not replaced by this workflow.
The Python package installs the NetBox application and its Python dependencies into a virtual environment using `pip`. Configuration, uploaded media, custom scripts and reports, collected static files, and deployment configuration remain outside the installed package.
This installation method does **not** configure PostgreSQL, Redis, a WSGI server, an HTTP server, or system services. These remain administrator-managed deployment tasks, just as they are for an archive or Git installation.
## When to Use This Installation Method
Use the Python package for a new test or evaluation deployment when you want `pip` to manage the NetBox application code in a dedicated virtual environment. While this workflow remains experimental, use a [release archive or Git checkout](3-netbox.md) for production deployments.
A package installation is also available as a migration target for an existing deployment, but it is not an in-place conversion. Follow the [migration procedure](#migrate-an-existing-archive-or-git-installation) only after validating the workflow in a separate environment.
## Understand the Installation Layout
A package installation separates the application code from the files that belong to a particular NetBox instance.
| Component | Example Location | Purpose |
|-----------|------------------|---------|
| Application code | `<venv>/lib/pythonX.Y/site-packages/` | Installed and replaced by `pip`; do not modify it directly |
| Python virtual environment | `/opt/netbox/venv/` | Contains NetBox, its dependencies, and any plugins |
| Instance root | `/opt/netbox/` | Holds local configuration and mutable instance data |
| Configuration | `/opt/netbox/conf/configuration.py` | Contains settings and credentials for this instance |
| Mutable data | `/opt/netbox/{media,reports,scripts,static}/` | Persists independently of package upgrades |
| Deployment examples | `/opt/netbox/contrib/` | Local copies to review and adapt before use |
The instance root defaults to `/opt/netbox` and may be changed with the `NETBOX_ROOT` environment variable. The virtual environment does not need to be located below the instance root; `/opt/netbox/venv` is used throughout this guide only to keep the example straightforward.
!!! note "Custom instance roots"
The `--target` option for `netbox setup` selects where the local files are created. It does not permanently set the instance root. When using a location other than `/opt/netbox`, set `NETBOX_ROOT` for all NetBox commands and services.
## Before You Begin
Complete the [PostgreSQL](1-postgresql.md) and [Redis](2-redis.md) installation steps first. Then install the same [required system packages](3-netbox.md#install-system-packages) used by the archive and Git installation methods.
## Create the System User and Instance Root
Create the `netbox` system account and the default instance root:
```no-highlight
sudo adduser --system --group netbox
sudo mkdir -p /opt/netbox
sudo chown root:netbox /opt/netbox
sudo chmod 755 /opt/netbox
```
## Create the Virtual Environment
Create a Python virtual environment and update `pip`:
```no-highlight
sudo python3 -m venv /opt/netbox/venv
sudo /opt/netbox/venv/bin/python -m pip install --upgrade pip
```
Install the desired NetBox release. Replace `X.Y.Z` with the exact version to install:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install "netbox==X.Y.Z"
```
Pinning the version makes the installed release explicit and prevents an unintended upgrade when the command is repeated later.
## Scaffold the Instance Root
Run `netbox setup` to create the local configuration skeleton and copy the bundled deployment examples:
```no-highlight
sudo /opt/netbox/venv/bin/netbox setup --target /opt/netbox
```
The command creates the following files when they do not already exist:
```no-highlight
/opt/netbox/
├── conf/
│ ├── __init__.py
│ └── configuration.py
├── contrib/
│ ├── apache.conf
│ ├── gunicorn.py
│ ├── netbox-rq.service
│ ├── netbox.env
│ ├── netbox.service
│ ├── nginx.conf
│ └── uwsgi.ini
└── local_requirements.txt
```
`netbox setup` is intentionally non-destructive: existing files are left untouched. It does not install systemd units, configure an HTTP server, rewrite deployment examples for the local paths, or enable plugins.
Create the directories used for mutable instance data and grant the NetBox service account ownership of them:
```no-highlight
sudo mkdir -p /opt/netbox/{media,reports,scripts,static}
sudo chown --recursive netbox:netbox \
/opt/netbox/media \
/opt/netbox/reports \
/opt/netbox/scripts \
/opt/netbox/static
```
## Configure NetBox
Open the scaffolded configuration file:
```no-highlight
sudo ${EDITOR:-vi} /opt/netbox/conf/configuration.py
```
Define the five [required configuration parameters](../configuration/required-parameters.md):
* `ALLOWED_HOSTS`
* `API_TOKEN_PEPPERS`
* `DATABASES`
* `REDIS`
* `SECRET_KEY`
Generate a suitable random value for `SECRET_KEY` with the installed command:
```no-highlight
sudo /opt/netbox/venv/bin/netbox secret-key
```
Run the command again to generate an independent value for the first entry in `API_TOKEN_PEPPERS`. Treat both values as sensitive and do not reuse the examples from the documentation.
After saving the configuration, restrict access while allowing the NetBox service account to read it:
```no-highlight
sudo chown --recursive root:netbox /opt/netbox/conf
sudo chmod 750 /opt/netbox/conf
sudo chmod 640 /opt/netbox/conf/configuration.py
```
!!! note "Environment-based configuration"
Ensure that any environment variables referenced by `configuration.py` are present when running `netbox upgrade`, `netbox createsuperuser`, and other management commands, and provide the same variables to both NetBox services. The copied `contrib/netbox.env` file is an example only and is not loaded automatically.
## Install Plugins and Optional Python Packages
Plugins and any other local Python requirements must be installed into the **same virtual environment** as NetBox before running the installation or upgrade tasks. Add each package to `/opt/netbox/local_requirements.txt`, then install the file:
```no-highlight
sudo ${EDITOR:-vi} /opt/netbox/local_requirements.txt
sudo /opt/netbox/venv/bin/python -m pip install \
-r /opt/netbox/local_requirements.txt
```
Installing a plugin does not enable it. Add the plugin to the `PLUGINS` list in `/opt/netbox/conf/configuration.py` and complete any plugin-specific configuration separately.
NetBox also provides optional package extras for several common integrations. For example, install the LDAP dependencies together with the same pinned NetBox version as follows:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
```
Remember which extras are in use and specify them again when upgrading. For LDAP authentication, create `ldap_config.py` beside the active configuration file at `/opt/netbox/conf/ldap_config.py` when following the [LDAP configuration guide](6-ldap.md). Give it the same ownership and permissions as `configuration.py`:
```no-highlight
sudo chown root:netbox /opt/netbox/conf/ldap_config.py
sudo chmod 640 /opt/netbox/conf/ldap_config.py
```
When using uWSGI, install `pyuwsgi` into the same virtual environment and record it as a local requirement:
```no-highlight
sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
sudo /opt/netbox/venv/bin/python -m pip install pyuwsgi
```
## Run the Installation Tasks
Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application installation tasks:
```no-highlight
sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
```
The `netbox upgrade` command is used for both a fresh package installation and future package upgrades. It replaces the source installation's `upgrade.sh` workflow.
For a custom instance root, pass `NETBOX_ROOT` explicitly. The virtual environment may remain elsewhere:
```no-highlight
sudo -u netbox env NETBOX_ROOT=/srv/netbox \
/opt/netbox-venv/bin/netbox upgrade --no-input
```
## Create a Superuser
Create the first administrative account:
```no-highlight
sudo -u netbox /opt/netbox/venv/bin/netbox createsuperuser
```
## Test the Application
Start Django's development server temporarily to confirm that NetBox can load its configuration and connect to its dependencies:
```no-highlight
sudo -u netbox /opt/netbox/venv/bin/netbox \
runserver 0.0.0.0:8000 --insecure
```
Connect to the server on port 8000 and log in with the superuser account. Type `Ctrl+c` to stop the development server after testing.
!!! danger "Not for production use"
The development server is intended only for installation testing. It is neither performant nor secure enough for production use.
## Adapt the Deployment Examples
The files copied to `/opt/netbox/contrib/` are the same deployment examples shipped for archive and Git installations. They are not rewritten for the package layout. Adapt them before following the shared Gunicorn, uWSGI, and HTTP server instructions.
For the default paths used in this guide, the following commands remove the source-tree references:
```no-highlight
sudo sed -i \
's| --pythonpath /opt/netbox/netbox||' \
/opt/netbox/contrib/netbox.service
sudo sed -i \
's|/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py|/opt/netbox/venv/bin/netbox|' \
/opt/netbox/contrib/netbox-rq.service
sudo sed -i \
's|chdir = netbox|chdir = /opt/netbox|' \
/opt/netbox/contrib/uwsgi.ini
sudo sed -i \
's|/opt/netbox/netbox/static|/opt/netbox/static|g' \
/opt/netbox/contrib/nginx.conf \
/opt/netbox/contrib/apache.conf
```
These changes have the following effect:
| File | Package Installation Change |
|------|-----------------------------|
| `netbox.service` | Imports `netbox.wsgi` from the virtual environment without a source-tree `--pythonpath` |
| `netbox-rq.service` | Runs the RQ worker through the installed `netbox` command instead of `manage.py` |
| `uwsgi.ini` | Uses the instance root rather than the absent `/opt/netbox/netbox/` source directory |
| `nginx.conf` and `apache.conf` | Serve collected static files from `/opt/netbox/static/` |
Review every file before installing it. When using a different instance root or virtual environment, update all `WorkingDirectory`, `ExecStart`, `chdir`, virtual environment, and static-file paths accordingly. Also add the following line to the `[Service]` section of both systemd units, replacing the path as needed:
```ini
Environment=NETBOX_ROOT=/srv/netbox
```
When using environment-based configuration, reference an appropriate environment file from both systemd units or define the required variables directly in each unit.
## Continue the Installation
With the deployment examples adapted, continue with either [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md). When using uWSGI and you installed `pyuwsgi` above, skip the **Installation** subsection on the uWSGI page and begin with its configuration steps. Then configure an [HTTP server](5-http-server.md) and, if needed, [LDAP authentication](6-ldap.md).
The shared pages copy files from `/opt/netbox/contrib/`, so make the package-specific changes above **before** copying those files into their final locations.
## Migrate an Existing Archive or Git Installation
!!! warning "Experimental migration path"
Migrating an existing deployment to the Python package changes its filesystem and upgrade model. Take a complete backup, document the current configuration, and verify a rollback procedure before proceeding.
Python package releases begin with NetBox v4.7. Before migrating an older deployment, first upgrade the existing archive or Git installation to a version that is available as a Python package.
Migrate the layout separately from a NetBox version upgrade. Install the **same NetBox version** that is currently running, validate the package-based deployment, and only then upgrade to a newer release.
The following example keeps the existing `/opt/netbox` installation in place during migration. It uses `/srv/netbox` as the new instance root and `/opt/netbox-venv` for the new virtual environment.
1. Stop the existing NetBox services after completing a backup:
```no-highlight
sudo systemctl stop netbox netbox-rq
```
2. Create the new virtual environment and install the same NetBox version as the existing deployment:
```no-highlight
sudo python3 -m venv /opt/netbox-venv
sudo /opt/netbox-venv/bin/python -m pip install --upgrade pip
sudo /opt/netbox-venv/bin/python -m pip install "netbox==X.Y.Z"
```
3. Scaffold the new instance root and create its mutable directories:
```no-highlight
sudo mkdir -p /srv/netbox
sudo chown root:netbox /srv/netbox
sudo chmod 755 /srv/netbox
sudo /opt/netbox-venv/bin/netbox setup --target /srv/netbox
sudo mkdir -p /srv/netbox/{media,reports,scripts,static}
sudo chown --recursive netbox:netbox \
/srv/netbox/media \
/srv/netbox/reports \
/srv/netbox/scripts \
/srv/netbox/static
```
4. Copy the active configuration from the existing installation. If `local_requirements.txt` exists, copy it over the empty file created by `netbox setup`:
```no-highlight
sudo cp /opt/netbox/netbox/netbox/configuration.py \
/srv/netbox/conf/configuration.py
if [ -f /opt/netbox/local_requirements.txt ]; then
sudo cp /opt/netbox/local_requirements.txt \
/srv/netbox/local_requirements.txt
fi
```
When the existing deployment uses `NETBOX_CONFIGURATION`, copy the active configuration module instead, together with any sibling modules or local files it imports. Review the copied configuration and update any filesystem paths that still reference the old source tree.
If LDAP is configured, also copy the active `ldap_config.py` to `/srv/netbox/conf/ldap_config.py`.
5. Copy locally stored media, reports, and scripts. Do not copy collected static files; `netbox upgrade` will create them again.
```no-highlight
sudo cp -a /opt/netbox/netbox/media/. /srv/netbox/media/
sudo cp -a /opt/netbox/netbox/reports/. /srv/netbox/reports/
sudo cp -a /opt/netbox/netbox/scripts/. /srv/netbox/scripts/
sudo chown --recursive netbox:netbox \
/srv/netbox/media \
/srv/netbox/reports \
/srv/netbox/scripts
```
Use the paths configured by `MEDIA_ROOT`, `REPORTS_ROOT`, and `SCRIPTS_ROOT` instead when the existing deployment stores these files elsewhere.
6. Install all plugins and local requirements into the new virtual environment **before** running the upgrade tasks:
```no-highlight
sudo /opt/netbox-venv/bin/python -m pip install \
-r /srv/netbox/local_requirements.txt
```
Repeat any NetBox package extras used by the deployment, and verify that each plugin supports the installed NetBox version.
7. Secure the configuration and run the package installation tasks against the existing database:
```no-highlight
sudo chown --recursive root:netbox /srv/netbox/conf
sudo chmod 750 /srv/netbox/conf
sudo chmod 640 /srv/netbox/conf/configuration.py
sudo -u netbox env NETBOX_ROOT=/srv/netbox \
/opt/netbox-venv/bin/netbox upgrade --no-input
```
If `ldap_config.py` was copied, also run `sudo chmod 640 /srv/netbox/conf/ldap_config.py`.
8. Follow [Adapt the Deployment Examples](#adapt-the-deployment-examples), substituting `/srv/netbox` and `/opt/netbox-venv` for the example paths. Install the updated systemd and HTTP server configuration, switch the services to the package deployment, and ensure that both systemd units define `NETBOX_ROOT=/srv/netbox`.
9. Start the services, test the web interface and background processing, and retain the previous installation until the new deployment has been validated:
```no-highlight
sudo systemctl start netbox netbox-rq
```
After the migration is complete, use the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental) for future releases.

View File

@ -12,18 +12,30 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
### Install django-auth-ldap
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
=== "Release archive or Git"
```no-highlight
source /opt/netbox/venv/bin/activate
pip3 install django-auth-ldap
```
Activate the Python virtual environment and install the `django-auth-ldap` package using pip:
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight
source /opt/netbox/venv/bin/activate
pip3 install django-auth-ldap
```
```no-highlight
sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
```
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight
sudo sh -c "echo 'django-auth-ldap' >> /opt/netbox/local_requirements.txt"
```
=== "Python package (experimental)"
Install NetBox's `ldap` optional dependency group, pinned to the installed NetBox version:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install "netbox[ldap]==X.Y.Z"
```
Specify the `ldap` extra again when upgrading the NetBox package. See the [Python package upgrade procedure](upgrading.md#upgrade-a-python-package-installation-experimental).
## Configuration
@ -33,7 +45,14 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```
Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
Next, create a file named `ldap_config.py` in the same directory as the active `configuration.py`. This is typically `/opt/netbox/netbox/netbox/` for a release archive or Git installation, or `/opt/netbox/conf/` for a Python package installation. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
For a Python package installation, protect the file while allowing the NetBox service account to read it:
```no-highlight
sudo chown root:netbox /opt/netbox/conf/ldap_config.py
sudo chmod 640 /opt/netbox/conf/ldap_config.py
```
### General Server Configuration

View File

@ -18,11 +18,16 @@ The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
2. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
3. Install the NetBox application using either:
* a [release archive or Git checkout](3-netbox.md); or
* the [Python package](3b-python-package.md) (experimental)
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
!!! warning "Experimental Python package installation"
Installing NetBox from the Python package is experimental in NetBox v4.7 and is not recommended for production use. It is intended for evaluation and feedback. The release archive and Git workflows remain supported and are the established installation methods.
## Requirements
| Dependency | Supported Versions |

View File

@ -22,11 +22,13 @@ block-beta
!!! warning "Perform a Backup"
Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
## 1. Review the Release Notes
## Review the Release Notes
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
## 2. Update Dependencies to Required Versions
Before proceeding, verify that all installed plugins support the target NetBox release.
## Update Required Dependencies
NetBox requires the following dependencies:
@ -56,7 +58,11 @@ NetBox requires the following dependencies:
| 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) |
## 3. Install the Latest Release
## Upgrade a Release Archive or Git Installation
The following procedure applies to NetBox installations created from a release archive or Git checkout. Complete the preparation steps above, then use the same installation method that was used for the existing deployment.
### 1. Install the Latest Release
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
@ -71,7 +77,7 @@ ls -ld /opt/netbox /opt/netbox/.git
If NetBox was installed from a release package, then `/opt/netbox` will be a symlink pointing to the current version, and `/opt/netbox/.git` will not exist. If it was installed from git, then `/opt/netbox` and `/opt/netbox/.git` will both exist as normal directories.
### Option A: Download a Release
#### Option A: Download a Release
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
@ -114,7 +120,7 @@ If you followed the original installation guide to set up gunicorn, be sure to c
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
```
### Option B: Check Out a Git Release
#### Option B: Check Out a Git Release
This guide assumes that NetBox is installed in `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
@ -133,7 +139,7 @@ sudo git fetch --tags && \
sudo git checkout v4.5.0
```
## 4. Run the Upgrade Script
### 2. Run the Upgrade Script
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
@ -167,7 +173,7 @@ This script performs the following actions:
been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
intentionally modifying the database schema.
## 5. Restart the NetBox Services
### 3. Restart the NetBox Services
!!! warning
If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
@ -177,3 +183,83 @@ Finally, restart the gunicorn and RQ services:
```no-highlight
sudo systemctl restart netbox netbox-rq
```
## Upgrade a Python Package Installation (Experimental)
!!! warning "Experimental installation method"
Installing NetBox from the Python package is experimental in NetBox v4.7 and is **not recommended for production use**. Test the upgrade and rollback procedures in a non-production environment before relying on them.
This procedure applies only to a deployment created using the [Python package installation method](3b-python-package.md). A package installation does not use `upgrade.sh`; use the installed `netbox upgrade` command instead. For a release archive or Git installation, follow the [procedure above](#upgrade-a-release-archive-or-git-installation).
Complete the preparation steps at the beginning of this page before proceeding.
### 1. Stop the NetBox Services
Stop the web application and background worker services before changing packages in the virtual environment:
```no-highlight
sudo systemctl stop netbox netbox-rq
```
### 2. Upgrade NetBox and Local Requirements
Install the target NetBox version into the existing virtual environment. Replace `X.Y.Z` with the exact version being installed:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install --upgrade "netbox==X.Y.Z"
```
If the deployment uses a package extra, include it in the upgrade command. For example, specify the `ldap` extra again when upgrading a deployment that uses LDAP authentication:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install --upgrade \
"netbox[ldap]==X.Y.Z"
```
Install all plugins and other local Python requirements into the same virtual environment **before** running the NetBox upgrade tasks:
```no-highlight
sudo /opt/netbox/venv/bin/python -m pip install \
-r /opt/netbox/local_requirements.txt
```
!!! note "Changing the Python version"
A virtual environment cannot be moved to a different Python interpreter in place. If the target NetBox release requires another Python version, create a replacement virtual environment, install the target NetBox package and all local requirements into it, and update the service executable paths before restarting NetBox.
### 3. Run the Upgrade Tasks
Run the packaged upgrade command to apply database migrations, collect static files, and perform the remaining application upgrade tasks:
```no-highlight
sudo -u netbox /opt/netbox/venv/bin/netbox upgrade --no-input
```
For a non-default instance root or a virtual environment stored elsewhere, use the applicable paths and set `NETBOX_ROOT` explicitly:
```no-highlight
sudo -u netbox env NETBOX_ROOT=/srv/netbox \
/opt/netbox-venv/bin/netbox upgrade --no-input
```
Ensure that any environment variables referenced by the NetBox configuration are also available when running this command.
### 4. Review the Deployment Configuration
`netbox setup` is not part of a routine upgrade. It leaves existing configuration and deployment examples untouched. To compare the examples bundled with the new package against the local copies without modifying the instance root, scaffold them into a temporary directory:
```no-highlight
EXAMPLES_DIR=$(mktemp -d)
/opt/netbox/venv/bin/netbox setup --target "$EXAMPLES_DIR"
diff --recursive /opt/netbox/contrib "$EXAMPLES_DIR/contrib"
rm -rf "$EXAMPLES_DIR"
```
The comparison will also show the package-layout changes made when the deployment examples were first adapted. Distinguish these local changes from updates introduced by the new release, and merge any relevant updates into the administrator-managed systemd, WSGI, and HTTP server configuration.
### 5. Start the NetBox Services
Start the services and verify that both the web application and background workers are operating normally:
```no-highlight
sudo systemctl start netbox netbox-rq
```

View File

@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
### Type
The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface.
!!! note
The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver.
### Channels
For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id).
A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel.
### Speed
The operating speed, in kilobits per second (kbps).
@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected.
### Parent Interface
Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface.
!!! note
An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
### Channel ID
For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
!!! note "Channel IDs are one-indexed"
Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable.
### Bridged Interface
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

View File

@ -1,6 +1,6 @@
# Jinja Config Templates
NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md#configuration-templates). Plugins can extend this rendering pipeline in two complementary ways:
NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/configuration-rendering.md). Plugins can extend this rendering pipeline in two complementary ways:
1. **Register custom filters** — make new template filters available by name in every config template.
2. **Inject context variables** — add extra variables that are available inside every config template render.

View File

@ -25,9 +25,9 @@ class MyModelType:
@strawberry.type
class MyQuery:
@strawberry.field
def dummymodel(self, id: int) -> DummyModelType:
def mymodel(self, id: int) -> MyModelType:
return None
dummymodel_list: list[DummyModelType] = strawberry_django.field()
mymodel_list: list[MyModelType] = strawberry_django.field()
schema = [
@ -35,6 +35,94 @@ schema = [
]
```
## Extending Core Types & Filters
!!! info "This feature was introduced in NetBox v4.6."
In addition to registering its own top-level query fields, a plugin can inject fields and filters onto NetBox's **existing** core GraphQL types (e.g. `DeviceType`). This allows a plugin's related data to be traversed within a single query rooted at a core object, rather than requiring a separate top-level query. This mirrors the `PluginTemplateExtension` mechanism used to extend core object views in the UI.
An extension is a mixin class declaring a `models` attribute: a list of the lowercased `app_label.model` labels of the core types it extends. Output-type extensions are collected from `graphql.type_extensions` and filter extensions from `graphql.filter_extensions` by default; these paths can be overridden via the `graphql_type_extensions` and `graphql_filter_extensions` attributes on the PluginConfig.
Each declared path must resolve to a list named `type_extensions` (or `filter_extensions`) - for example, defined in `graphql.py` alongside the schema, or re-exported from the plugin's `graphql` package.
!!! warning
Do not import core GraphQL modules (e.g. `dcim.graphql.types`) from a plugin's `ready()`. Doing so assembles the affected core types before other plugins have registered their extensions, which are then silently dropped. A warning is logged under `netbox.graphql` if this occurs.
### Type Extensions
An output-type extension is a `@strawberry.type` class whose fields and resolvers are spliced into the target type:
```python
# graphql.py (or graphql/type_extensions.py)
from typing import Annotated
import strawberry
import strawberry_django
from utilities.querysets import RestrictedPrefetch
from my_plugin.models import Widget
@strawberry.type
class DeviceTypeExtension:
models = ['dcim.device']
@strawberry_django.field(
prefetch_related=lambda info: RestrictedPrefetch(
'widgets', info.context.request.user, 'view', queryset=Widget.objects.all()
),
)
def widgets(self) -> list[Annotated['WidgetType', strawberry.lazy('my_plugin.graphql.types')]]:
return self.widgets.all()
type_extensions = [
DeviceTypeExtension,
]
```
!!! note
Scope any related-object resolver with `RestrictedPrefetch(..., info.context.request.user, 'view', ...)`, as shown above. Object permissions are only applied to the top-level queryset, so a plain `prefetch_related='widgets'` returns related objects the requesting user may not be permitted to see.
### Filter Extensions
A filter extension is a `@strawberry.type` class declaring additional filters - either as annotated filter fields or as custom filter methods - which are spliced into the target filter:
```python
# graphql.py (or graphql/filter_extensions.py)
import strawberry
import strawberry_django
from django.db.models import Q
@strawberry.type
class DeviceFilterExtension:
models = ['dcim.device']
@strawberry_django.filter_field()
def has_widgets(self, value: bool, prefix) -> Q:
return Q(**{f'{prefix}widgets__isnull': not value})
filter_extensions = [
DeviceFilterExtension,
]
```
With both registered, a client can fetch a device and its plugin-provided data in a single query:
```graphql
query {
device_list(filters: { has_widgets: true }) {
name
widgets { id name }
}
}
```
!!! note
Extensions are strictly additive: they can only add new fields, never replace existing ones. If an extension declares a name the core type already provides, the core definition always takes precedence and the extension's version is ignored. If two extensions on the same type declare the same new name, the one whose plugin is loaded first (earlier in `PLUGINS`) wins. Both cases are logged as warnings under the `netbox.graphql` logger.
## GraphQL Objects
NetBox provides two object type classes for use by plugins.

View File

@ -123,6 +123,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `menu` | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
| `graphql_type_extensions` | The dotted path to the list of GraphQL output-type extension classes, if any (default: `graphql.type_extensions`) |
| `graphql_filter_extensions` | The dotted path to the list of GraphQL filter extension classes, if any (default: `graphql.filter_extensions`) |
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.

View File

@ -98,7 +98,8 @@ nav:
- Installing NetBox: 'installation/index.md'
- 1. PostgreSQL: 'installation/1-postgresql.md'
- 2. Redis: 'installation/2-redis.md'
- 3. NetBox: 'installation/3-netbox.md'
- 3a. Release Archive or Git: 'installation/3-netbox.md'
- 3b. Python Package (Experimental): 'installation/3b-python-package.md'
- 4a. Gunicorn: 'installation/4a-gunicorn.md'
- 4b. uWSGI: 'installation/4b-uwsgi.md'
- 5. HTTP Server: 'installation/5-http-server.md'

View File

@ -10,7 +10,12 @@ from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
from netbox.graphql.filters import (
ChangeLoggedModelFilter,
OrganizationalModelFilter,
PrimaryModelFilter,
register_filter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
if TYPE_CHECKING:
@ -36,7 +41,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.CircuitTermination, lookups=True)
@register_filter(models.CircuitTermination, lookups=True)
class CircuitTerminationFilter(
CustomFieldsFilterMixin,
TagsFilterMixin,
@ -83,7 +88,7 @@ class CircuitTerminationFilter(
)
@strawberry_django.filter_type(models.Circuit, lookups=True)
@register_filter(models.Circuit, lookups=True)
class CircuitFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
@ -117,17 +122,17 @@ class CircuitFilter(
)
@strawberry_django.filter_type(models.CircuitType, lookups=True)
@register_filter(models.CircuitType, lookups=True)
class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.CircuitGroup, lookups=True)
@register_filter(models.CircuitGroup, lookups=True)
class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
@register_filter(models.CircuitGroupAssignment, lookups=True)
class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -142,7 +147,7 @@ class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, Cha
)
@strawberry_django.filter_type(models.Provider, lookups=True)
@register_filter(models.Provider, lookups=True)
class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -152,7 +157,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.ProviderAccount, lookups=True)
@register_filter(models.ProviderAccount, lookups=True)
class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -162,7 +167,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
@register_filter(models.ProviderNetwork, lookups=True)
class ProviderNetworkFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -172,12 +177,12 @@ class ProviderNetworkFilter(PrimaryModelFilter):
service_id: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
@register_filter(models.VirtualCircuitType, lookups=True)
class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
@register_filter(models.VirtualCircuit, lookups=True)
class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
cid: StrFilterLookup | None = strawberry_django.filter_field()
provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@ -200,7 +205,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
@register_filter(models.VirtualCircuitTermination, lookups=True)
class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
strawberry_django.filter_field()

View File

@ -6,7 +6,7 @@ import strawberry_django
from circuits import models
from dcim.graphql.mixins import CabledObjectMixin
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType, register_type
from tenancy.graphql.types import TenantType
from .filters import *
@ -30,7 +30,7 @@ __all__ = (
)
@strawberry_django.type(
@register_type(
models.Provider,
fields='__all__',
filters=ProviderFilter,
@ -43,7 +43,7 @@ class ProviderType(ContactsMixin, PrimaryObjectType):
accounts: list[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ProviderAccount,
fields='__all__',
filters=ProviderAccountFilter,
@ -54,7 +54,7 @@ class ProviderAccountType(ContactsMixin, PrimaryObjectType):
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ProviderNetwork,
fields='__all__',
filters=ProviderNetworkFilter,
@ -65,7 +65,7 @@ class ProviderNetworkType(PrimaryObjectType):
circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.CircuitTermination,
exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'],
filters=CircuitTerminationFilter,
@ -86,7 +86,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
return self.termination
@strawberry_django.type(
@register_type(
models.CircuitType,
fields='__all__',
filters=CircuitTypeFilter,
@ -98,7 +98,7 @@ class CircuitTypeType(OrganizationalObjectType):
circuits: list[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Circuit,
fields='__all__',
filters=CircuitFilter,
@ -114,7 +114,7 @@ class CircuitType(PrimaryObjectType, ContactsMixin):
terminations: list[CircuitTerminationType]
@strawberry_django.type(
@register_type(
models.CircuitGroup,
fields='__all__',
filters=CircuitGroupFilter,
@ -124,7 +124,7 @@ class CircuitGroupType(OrganizationalObjectType):
tenant: TenantType | None
@strawberry_django.type(
@register_type(
models.CircuitGroupAssignment,
exclude=['member_type', 'member_id'],
filters=CircuitGroupAssignmentFilter,
@ -142,7 +142,7 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
return self.member
@strawberry_django.type(
@register_type(
models.VirtualCircuitType,
fields='__all__',
filters=VirtualCircuitTypeFilter,
@ -154,7 +154,7 @@ class VirtualCircuitTypeType(OrganizationalObjectType):
virtual_circuits: list[Annotated["VirtualCircuitType", strawberry.lazy('circuits.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VirtualCircuitTermination,
fields='__all__',
filters=VirtualCircuitTerminationFilter,
@ -171,7 +171,7 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
] = strawberry_django.field(select_related=["interface"])
@strawberry_django.type(
@register_type(
models.VirtualCircuit,
fields='__all__',
filters=VirtualCircuitFilter,

View File

@ -32,6 +32,7 @@ class CoreConfig(AppConfig):
from core.checks import check_duplicate_indexes, check_postgresql_version, check_redis_version # noqa: F401
from netbox import context_managers # noqa: F401
from netbox.models.features import register_models
from netbox.search import signals as search_signals # noqa: F401
from . import data_backends, events, search # noqa: F401

View File

@ -7,7 +7,7 @@ from strawberry.scalars import ID
from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
from core import models
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter, register_filter
from .enums import *
@ -23,7 +23,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.DataFile, lookups=True)
@register_filter(models.DataFile, lookups=True)
class DataFileFilter(BaseModelFilter):
created: DatetimeFilterLookup | None = strawberry_django.filter_field()
last_updated: DatetimeFilterLookup | None = strawberry_django.filter_field()
@ -38,7 +38,7 @@ class DataFileFilter(BaseModelFilter):
hash: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DataSource, lookups=True)
@register_filter(models.DataSource, lookups=True)
class DataSourceFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
type: StrFilterLookup | None = strawberry_django.filter_field()
@ -57,7 +57,7 @@ class DataSourceFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.ObjectChange, lookups=True)
@register_filter(models.ObjectChange, lookups=True)
class ObjectChangeFilter(BaseModelFilter):
time: DatetimeFilterLookup | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@ -84,7 +84,7 @@ class ObjectChangeFilter(BaseModelFilter):
)
@strawberry_django.filter_type(DjangoContentType, lookups=True)
@register_filter(DjangoContentType, lookups=True)
class ContentTypeFilter(BaseModelFilter):
app_label: StrFilterLookup | None = strawberry_django.filter_field()
model: StrFilterLookup | None = strawberry_django.filter_field()

View File

@ -1,11 +1,10 @@
from typing import Annotated
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType as DjangoContentType
from core import models
from netbox.graphql.types import BaseObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, PrimaryObjectType, register_type
from .filters import *
@ -17,7 +16,7 @@ __all__ = (
)
@strawberry_django.type(
@register_type(
models.DataFile,
exclude=['data',],
filters=DataFileFilter,
@ -27,7 +26,7 @@ class DataFileType(BaseObjectType):
source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')]
@strawberry_django.type(
@register_type(
models.DataSource,
fields='__all__',
filters=DataSourceFilter,
@ -37,7 +36,7 @@ class DataSourceType(PrimaryObjectType):
datafiles: list[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ObjectChange,
fields='__all__',
filters=ObjectChangeFilter,
@ -47,7 +46,7 @@ class ObjectChangeType(BaseObjectType):
pass
@strawberry_django.type(
@register_type(
DjangoContentType,
fields='__all__',
pagination=True

View File

@ -1,10 +1,10 @@
from django.core.cache import cache
from django.db import models
from django.db import models, router, transaction
from django.urls import reverse
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from utilities.querysets import RestrictedQuerySet
from utilities.querysets import RestrictedQuerySet, chunked_update
__all__ = (
'ConfigRevision',
@ -78,9 +78,15 @@ class ConfigRevision(models.Model):
cache.set('config_version', self.pk, None)
if update_db:
# Set all instances of ConfigRevision to false and set this instance to true
ConfigRevision.objects.all().update(active=False)
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
# Set all instances of ConfigRevision to false and set this instance to true. Wrap both
# statements in a transaction so the "exactly one active revision" invariant is preserved
# even when the deactivation is chunked into multiple statements. Resolve the write alias
# once and pin the transaction and both querysets to it, so the transaction genuinely
# covers the (potentially router-directed) writes performed by chunked_update().
using = router.db_for_write(ConfigRevision)
with transaction.atomic(using=using):
chunked_update(ConfigRevision.objects.using(using).all(), active=False)
ConfigRevision.objects.using(using).filter(pk=self.pk).update(active=True)
activate.alters_data = True

View File

@ -220,7 +220,9 @@ class DataSource(JobsMixin, PrimaryModel):
continue
# Bulk update modified files
updated_count = DataFile.objects.bulk_update(updated_files, ('last_updated', 'size', 'hash', 'data'))
updated_count = DataFile.objects.bulk_update(
updated_files, ('last_updated', 'size', 'hash', 'data'), batch_size=settings.BULK_UPDATE_CHUNK_SIZE
)
logger.debug(f"Updated {updated_count} files")
# Bulk delete deleted files

View File

@ -263,7 +263,8 @@ class InterfaceSerializer(
class Meta:
model = Interface
fields = [
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
'channel_id', 'enabled',
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',

View File

@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceTemplateSerializer(
required=False,
allow_null=True
)
bridge = NestedInterfaceTemplateSerializer(
required=False,
allow_null=True
@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled',
'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id',
'enabled', 'mgmt_only', 'description', 'parent', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -914,6 +914,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_VIRTUAL = 'virtual'
TYPE_BRIDGE = 'bridge'
TYPE_LAG = 'lag'
TYPE_CHANNEL = 'channel'
# FastEthernet
TYPE_100ME_FX = '100base-fx'
@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
Choice(TYPE_VIRTUAL, _('Virtual')),
Choice(TYPE_BRIDGE, _('Bridge')),
Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')),
Choice(TYPE_CHANNEL, _('Channel')),
),
),
(

View File

@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024
INTERFACE_MTU_MIN = 1
INTERFACE_MTU_MAX = 65536
# The number of channels on a channelized interface, and the channel to which a subinterface is bound. A subinterface's
# channel_id maps directly to a position on the parent interface's cable connector, so these are bounded by the maximum
# cable position.
INTERFACE_CHANNELS_MIN = CABLE_POSITION_MIN
INTERFACE_CHANNELS_MAX = CABLE_POSITION_MAX
VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG,
@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_5G,
]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
# Channel subinterfaces derive their cable from the (channelized) parent interface and cannot be cabled directly
InterfaceTypeChoices.TYPE_CHANNEL,
]
#

View File

@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
distinct=False,
null_value=None
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=InterfaceTemplate.objects.all(),
distinct=False,
)
bridge_id = django_filters.ModelMultipleChoiceFilter(
field_name='bridge',
queryset=InterfaceTemplate.objects.all(),
@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
model = InterfaceTemplate
fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
fields = ('id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description')
@register_filterset
@ -2416,9 +2421,9 @@ class InterfaceFilterSet(
class Meta:
model = Interface
fields = (
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
'cable_id', 'cable_end', 'cable_connector',
'id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'description', 'mark_connected', 'cable_id', 'cable_end', 'cable_connector',
)
def filter_virtual_chassis_member_or_master(self, queryset, name, value):

View File

@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
choices=add_blank_choice(InterfaceTypeChoices),
required=False
)
channels = forms.IntegerField(
label=_('Channels'),
required=False
)
channel_id = forms.IntegerField(
label=_('Channel ID'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
label=_('Wireless role')
)
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
nullable_fields = ('label', 'channels', 'channel_id', 'description', 'poe_mode', 'poe_type', 'rf_role')
class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm(
ComponentBulkEditForm,
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'wireless_lans', 'vlan_translation_policy'
'label', 'type', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu',
'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'wireless_lans', 'vlan_translation_policy'
])
):
enabled = forms.NullBooleanField(
@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm(
model = Interface
fieldsets = (
FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'),
FieldSet('vrf', 'wwn', name=_('Addressing')),
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
),
@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm(
),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs',
'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
)
def __init__(self, *args, **kwargs):

View File

@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
)

View File

@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet(
'name', 'label', 'kind', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled', 'mgmt_only',
name=_('Attributes')
),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
choices=InterfaceTypeChoices,
required=False
)
channels = forms.IntegerField(
label=_('Channels'),
required=False
)
channel_id = forms.IntegerField(
label=_('Channel ID'),
required=False
)
speed = PositiveBigIntegerField(
label=_('Speed'),
required=False,

View File

@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
choices=add_blank_choice(WirelessRoleChoices),
required=False,
)
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type',
'module_type_id': '$module_type',
}
)
bridge = DynamicModelChoiceField(
label=_('Bridge'),
queryset=InterfaceTemplate.objects.all(),
@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description', 'parent',
'bridge',
),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', name=_('Wireless')),
@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode',
'poe_type', 'bridge', 'rf_role',
'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'mgmt_only', 'enabled',
'description', 'poe_mode', 'poe_type', 'parent', 'bridge', 'rf_role',
]
@ -1939,11 +1949,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
fieldsets = (
FieldSet(
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
'device', 'module', 'name', 'label', 'type', 'channels', 'speed', 'duplex', 'description', 'tags',
name=_('Interface')
),
FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
class Meta:
model = Interface
fields = [
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
'owner', 'tags',
'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex',
'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
'vrf', 'owner', 'tags',
]
widgets = {
'speed': NumberWithOptions(

View File

@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import OwnerMixin
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.fields import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
ExpandableNameField,
ExpandableNumericField,
)
from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect
@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
channel_id = ExpandableNumericField(
label=_('Channel ID'),
required=False
)
replication_fields = ('name', 'label', 'channel_id')
class Meta(model_forms.InterfaceTemplateForm.Meta):
exclude = ('name', 'label')
exclude = ('name', 'label', 'channel_id')
class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
channel_id = ExpandableNumericField(
label=_('Channel ID'),
required=False
)
replication_fields = ('name', 'label', 'channel_id')
class Meta(model_forms.InterfaceForm.Meta):
exclude = ('name', 'label')
exclude = ('name', 'label', 'channel_id')
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):

View File

@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
'poe_type', 'rf_role'
'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only',
'description', 'poe_mode', 'poe_type', 'rf_role'
]

View File

@ -31,6 +31,7 @@ from netbox.graphql.filters import (
NetBoxModelFilter,
OrganizationalModelFilter,
PrimaryModelFilter,
register_filter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
@ -121,12 +122,12 @@ __all__ = (
)
@strawberry_django.filter_type(models.CableBundle, lookups=True)
@register_filter(models.CableBundle, lookups=True)
class CableBundleFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Cable, lookups=True)
@register_filter(models.Cable, lookups=True)
class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -149,7 +150,7 @@ class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.CableTermination, lookups=True)
@register_filter(models.CableTermination, lookups=True)
class CableTerminationFilter(ChangeLoggedModelFilter):
cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
cable_id: ID | None = strawberry_django.filter_field()
@ -176,7 +177,7 @@ class CableTerminationFilter(ChangeLoggedModelFilter):
)
@strawberry_django.filter_type(models.ConsolePort, lookups=True)
@register_filter(models.ConsolePort, lookups=True)
class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -186,14 +187,14 @@ class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi
)
@strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
@register_filter(models.ConsolePortTemplate, lookups=True)
class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
@register_filter(models.ConsoleServerPort, lookups=True)
class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -203,14 +204,14 @@ class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilt
)
@strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
@register_filter(models.ConsoleServerPortTemplate, lookups=True)
class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.Device, lookups=True)
@register_filter(models.Device, lookups=True)
class DeviceFilter(
ContactFilterMixin,
TenancyFilterMixin,
@ -329,7 +330,7 @@ class DeviceFilter(
inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceBay, lookups=True)
@register_filter(models.DeviceBay, lookups=True)
class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@ -338,12 +339,12 @@ class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
installed_device_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
@register_filter(models.DeviceBayTemplate, lookups=True)
class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
@register_filter(models.InventoryItemTemplate, lookups=True)
class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -363,7 +364,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedMode
part_id: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceRole, lookups=True)
@register_filter(models.DeviceRole, lookups=True)
class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -371,7 +372,7 @@ class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
vm_role: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.DeviceType, lookups=True)
@register_filter(models.DeviceType, lookups=True)
class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -448,7 +449,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.FrontPort, lookups=True)
@register_filter(models.FrontPort, lookups=True)
class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -458,7 +459,7 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
)
@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
@register_filter(models.FrontPortTemplate, lookups=True)
class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -468,7 +469,7 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
)
@strawberry_django.filter_type(models.PortMapping, lookups=True)
@register_filter(models.PortMapping, lookups=True)
class PortMappingFilter(BaseModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@ -481,7 +482,7 @@ class PortMappingFilter(BaseModelFilter):
rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True)
@register_filter(models.PortTemplateMapping, lookups=True)
class PortTemplateMappingFilter(BaseModelFilter):
device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -499,7 +500,7 @@ class PortTemplateMappingFilter(BaseModelFilter):
rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.MACAddress, lookups=True)
@register_filter(models.MACAddress, lookups=True)
class MACAddressFilter(PrimaryModelFilter):
mac_address: StrFilterLookup | None = strawberry_django.filter_field()
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
@ -525,7 +526,7 @@ class MACAddressFilter(PrimaryModelFilter):
return ~Q(query)
@strawberry_django.filter_type(models.Interface, lookups=True)
@register_filter(models.Interface, lookups=True)
class InterfaceFilter(
ModularComponentFilterMixin,
InterfaceBaseFilterMixin,
@ -540,6 +541,12 @@ class InterfaceFilter(
type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@ -624,13 +631,23 @@ class InterfaceFilter(
return queryset, Q()
@strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
@register_filter(models.InterfaceTemplate, lookups=True)
class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
parent: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
parent_id: ID | None = strawberry_django.filter_field()
bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@ -646,7 +663,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
)
@strawberry_django.filter_type(models.InventoryItem, lookups=True)
@register_filter(models.InventoryItem, lookups=True)
class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -673,14 +690,14 @@ class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
discovered: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
@register_filter(models.InventoryItemRole, lookups=True)
class InventoryItemRoleFilter(OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.Location, lookups=True)
@register_filter(models.Location, lookups=True)
class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -696,12 +713,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
)
@strawberry_django.filter_type(models.Manufacturer, lookups=True)
@register_filter(models.Manufacturer, lookups=True)
class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.Module, lookups=True)
@register_filter(models.Module, lookups=True)
class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
@ -750,7 +767,7 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.ModuleBay, lookups=True)
@register_filter(models.ModuleBay, lookups=True)
class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -764,7 +781,7 @@ class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
module_bay_type_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
@register_filter(models.ModuleBayTemplate, lookups=True)
class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
position: StrFilterLookup | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@ -774,7 +791,7 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
module_bay_type_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleBayType, lookups=True)
@register_filter(models.ModuleBayType, lookups=True)
class ModuleBayTypeFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -784,12 +801,12 @@ class ModuleBayTypeFilter(PrimaryModelFilter):
manufacturer_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
@register_filter(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ModuleType, lookups=True)
@register_filter(models.ModuleType, lookups=True)
class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -842,7 +859,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Platform, lookups=True)
@register_filter(models.Platform, lookups=True)
class PlatformFilter(OrganizationalModelFilter):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -854,7 +871,7 @@ class PlatformFilter(OrganizationalModelFilter):
config_template_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.PowerFeed, lookups=True)
@register_filter(models.PowerFeed, lookups=True)
class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -889,7 +906,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
)
@strawberry_django.filter_type(models.PowerOutlet, lookups=True)
@register_filter(models.PowerOutlet, lookups=True)
class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -909,7 +926,7 @@ class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixi
)
@strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
@register_filter(models.PowerOutletTemplate, lookups=True)
class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -923,7 +940,7 @@ class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLogge
)
@strawberry_django.filter_type(models.PowerPanel, lookups=True)
@register_filter(models.PowerPanel, lookups=True)
class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -936,7 +953,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.PowerPort, lookups=True)
@register_filter(models.PowerPort, lookups=True)
class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -949,7 +966,7 @@ class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
)
@strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
@register_filter(models.PowerPortTemplate, lookups=True)
class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -962,7 +979,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
)
@strawberry_django.filter_type(models.RackType, lookups=True)
@register_filter(models.RackType, lookups=True)
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -977,7 +994,7 @@ class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMi
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Rack, lookups=True)
@register_filter(models.Rack, lookups=True)
class RackFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
@ -1022,12 +1039,12 @@ class RackFilter(
)
@strawberry_django.filter_type(models.RackGroup, lookups=True)
@register_filter(models.RackGroup, lookups=True)
class RackGroupFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.RackReservation, lookups=True)
@register_filter(models.RackReservation, lookups=True)
class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
rack_id: ID | None = strawberry_django.filter_field()
@ -1043,14 +1060,14 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.RackRole, lookups=True)
@register_filter(models.RackRole, lookups=True)
class RackRoleFilter(OrganizationalModelFilter):
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.RearPort, lookups=True)
@register_filter(models.RearPort, lookups=True)
class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -1063,7 +1080,7 @@ class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
)
@strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
@register_filter(models.RearPortTemplate, lookups=True)
class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -1076,7 +1093,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedMo
)
@strawberry_django.filter_type(models.Region, lookups=True)
@register_filter(models.Region, lookups=True)
class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -1086,7 +1103,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
)
@strawberry_django.filter_type(models.Site, lookups=True)
@register_filter(models.Site, lookups=True)
class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -1122,7 +1139,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
)
@strawberry_django.filter_type(models.SiteGroup, lookups=True)
@register_filter(models.SiteGroup, lookups=True)
class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -1132,7 +1149,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
)
@strawberry_django.filter_type(models.VirtualChassis, lookups=True)
@register_filter(models.VirtualChassis, lookups=True)
class VirtualChassisFilter(PrimaryModelFilter):
master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
master_id: ID | None = strawberry_django.filter_field()
@ -1144,7 +1161,7 @@ class VirtualChassisFilter(PrimaryModelFilter):
member_count: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
@register_filter(models.VirtualDeviceContext, lookups=True)
class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()

View File

@ -17,6 +17,7 @@ from netbox.graphql.types import (
NetBoxObjectType,
OrganizationalObjectType,
PrimaryObjectType,
register_type,
)
from users.graphql.mixins import OwnerMixin
from utilities.querysets import RestrictedPrefetch
@ -134,7 +135,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
#
@strawberry_django.type(
@register_type(
models.CableBundle,
fields='__all__',
filters=CableBundleFilter,
@ -144,7 +145,7 @@ class CableBundleType(PrimaryObjectType):
cables: list[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.CableTermination,
exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'],
filters=CableTerminationFilter,
@ -166,7 +167,7 @@ class CableTerminationType(NetBoxObjectType):
] | None
@strawberry_django.type(
@register_type(
models.Cable,
fields='__all__',
filters=CableFilter,
@ -206,7 +207,7 @@ class CableType(PrimaryObjectType):
]]
@strawberry_django.type(
@register_type(
models.ConsolePort,
exclude=['_path'],
filters=ConsolePortFilter,
@ -216,7 +217,7 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
pass
@strawberry_django.type(
@register_type(
models.ConsolePortTemplate,
fields='__all__',
filters=ConsolePortTemplateFilter,
@ -226,7 +227,7 @@ class ConsolePortTemplateType(ModularComponentTemplateType):
pass
@strawberry_django.type(
@register_type(
models.ConsoleServerPort,
exclude=['_path'],
filters=ConsoleServerPortFilter,
@ -236,7 +237,7 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin
pass
@strawberry_django.type(
@register_type(
models.ConsoleServerPortTemplate,
fields='__all__',
filters=ConsoleServerPortTemplateFilter,
@ -246,7 +247,7 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType):
pass
@strawberry_django.type(
@register_type(
models.Device,
fields='__all__',
filters=DeviceFilter,
@ -302,7 +303,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
return self.parent_bay if hasattr(self, 'parent_bay') else None
@strawberry_django.type(
@register_type(
models.DeviceBay,
fields='__all__',
filters=DeviceBayFilter,
@ -312,7 +313,7 @@ class DeviceBayType(ComponentType):
installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.DeviceBayTemplate,
fields='__all__',
filters=DeviceBayTemplateFilter,
@ -322,7 +323,7 @@ class DeviceBayTemplateType(ComponentTemplateType):
pass
@strawberry_django.type(
@register_type(
models.InventoryItemTemplate,
exclude=['component_type', 'component_id', 'parent', 'path'],
filters=InventoryItemTemplateFilter,
@ -350,7 +351,7 @@ class InventoryItemTemplateType(LtreeNodeMixin, ComponentTemplateType):
] | None
@strawberry_django.type(
@register_type(
models.DeviceRole,
exclude=['path', 'sort_path'],
filters=DeviceRoleFilter,
@ -366,7 +367,7 @@ class DeviceRoleType(NestedLtreeGroupObjectType):
devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.DeviceType,
fields='__all__',
filters=DeviceTypeFilter,
@ -402,7 +403,7 @@ class DeviceTypeType(PrimaryObjectType):
consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.FrontPort,
fields='__all__',
filters=FrontPortFilter,
@ -414,7 +415,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.FrontPortTemplate,
fields='__all__',
filters=FrontPortTemplateFilter,
@ -426,7 +427,7 @@ class FrontPortTemplateType(ModularComponentTemplateType):
mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.MACAddress,
exclude=['assigned_object_type', 'assigned_object_id'],
filters=MACAddressFilter,
@ -444,7 +445,7 @@ class MACAddressType(PrimaryObjectType):
return self.assigned_object
@strawberry_django.type(
@register_type(
models.Interface,
exclude=['_path'],
filters=InterfaceFilter,
@ -474,7 +475,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.InterfaceTemplate,
fields='__all__',
filters=InterfaceTemplateFilter,
@ -482,12 +483,14 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
)
class InterfaceTemplateType(ModularComponentTemplateType):
_name: str
parent: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.InventoryItem,
exclude=['component_type', 'component_id', 'parent', 'path'],
filters=InventoryItemFilter,
@ -515,7 +518,7 @@ class InventoryItemType(LtreeNodeMixin, ComponentType):
] | None
@strawberry_django.type(
@register_type(
models.InventoryItemRole,
fields='__all__',
filters=InventoryItemRoleFilter,
@ -528,7 +531,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
inventory_item_templates: list[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Location,
# fields='__all__',
exclude=['parent', 'path', 'sort_path'], # bug - temp
@ -566,7 +569,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Nested
return self.circuit_terminations.all()
@strawberry_django.type(
@register_type(
models.Manufacturer,
fields='__all__',
filters=ManufacturerFilter,
@ -581,7 +584,7 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin):
module_types: list[Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Module,
fields='__all__',
filters=ModuleFilter,
@ -601,7 +604,7 @@ class ModuleType(PrimaryObjectType):
frontports: list[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ModuleBay,
# fields='__all__',
exclude=['parent', 'path', 'sort_path'],
@ -619,7 +622,7 @@ class ModuleBayType(LtreeNodeMixin, ModularComponentType):
return self.parent
@strawberry_django.type(
@register_type(
models.ModuleBayTemplate,
fields='__all__',
filters=ModuleBayTemplateFilter,
@ -629,7 +632,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
module_bay_types: list[Annotated["ModuleBayTypeType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ModuleBayType,
fields='__all__',
filters=ModuleBayTypeFilter,
@ -643,7 +646,7 @@ class ModuleBayTypeType(PrimaryObjectType):
module_bay_templates: list[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ModuleTypeProfile,
fields='__all__',
filters=ModuleTypeProfileFilter,
@ -653,7 +656,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
module_types: list[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ModuleType,
fields='__all__',
filters=ModuleTypeFilter,
@ -683,7 +686,7 @@ class ModuleTypeType(PrimaryObjectType):
consoleporttemplates: list[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Platform,
exclude=['path', 'sort_path'],
filters=PlatformFilter,
@ -699,7 +702,7 @@ class PlatformType(NestedLtreeGroupObjectType):
devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.PortMapping,
fields='__all__',
filters=PortMappingFilter,
@ -710,7 +713,7 @@ class PortMappingType(ModularComponentTemplateType):
rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
@register_type(
models.PortTemplateMapping,
fields='__all__',
filters=PortTemplateMappingFilter,
@ -721,7 +724,7 @@ class PortMappingTemplateType(ModularComponentTemplateType):
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
@register_type(
models.PowerFeed,
exclude=['_path'],
filters=PowerFeedFilter,
@ -733,7 +736,7 @@ class PowerFeedType(CabledObjectMixin, PathEndpointMixin, PrimaryObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.PowerOutlet,
exclude=['_path'],
filters=PowerOutletFilter,
@ -744,7 +747,7 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
color: str
@strawberry_django.type(
@register_type(
models.PowerOutletTemplate,
fields='__all__',
filters=PowerOutletTemplateFilter,
@ -755,7 +758,7 @@ class PowerOutletTemplateType(ModularComponentTemplateType):
color: str
@strawberry_django.type(
@register_type(
models.PowerPanel,
fields='__all__',
filters=PowerPanelFilter,
@ -768,7 +771,7 @@ class PowerPanelType(ContactsMixin, PrimaryObjectType):
powerfeeds: list[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.PowerPort,
exclude=['_path'],
filters=PowerPortFilter,
@ -779,7 +782,7 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin):
poweroutlets: list[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.PowerPortTemplate,
fields='__all__',
filters=PowerPortTemplateFilter,
@ -789,7 +792,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
poweroutlet_templates: list[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RackGroup,
fields='__all__',
filters=RackGroupFilter,
@ -800,7 +803,7 @@ class RackGroupType(OrganizationalObjectType):
racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RackType,
fields='__all__',
filters=RackTypeFilter,
@ -811,7 +814,7 @@ class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
@register_type(
models.Rack,
fields='__all__',
filters=RackFilter,
@ -831,7 +834,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
cabletermination_set: list[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RackReservation,
fields='__all__',
filters=RackReservationFilter,
@ -855,7 +858,7 @@ class RackReservationType(PrimaryObjectType):
return len(self.units)
@strawberry_django.type(
@register_type(
models.RackRole,
fields='__all__',
filters=RackRoleFilter,
@ -867,7 +870,7 @@ class RackRoleType(OrganizationalObjectType):
racks: list[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RearPort,
fields='__all__',
filters=RearPortFilter,
@ -879,7 +882,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
mappings: list[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RearPortTemplate,
fields='__all__',
filters=RearPortTemplateFilter,
@ -891,7 +894,7 @@ class RearPortTemplateType(ModularComponentTemplateType):
mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Region,
exclude=['parent', 'path', 'sort_path'],
filters=RegionFilter,
@ -926,7 +929,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
return self.circuit_terminations.all()
@strawberry_django.type(
@register_type(
models.Site,
fields='__all__',
filters=SiteFilter,
@ -968,7 +971,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
return self.circuit_terminations.all()
@strawberry_django.type(
@register_type(
models.SiteGroup,
exclude=['parent', 'path', 'sort_path'], # bug - temp
filters=SiteGroupFilter,
@ -1003,7 +1006,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedLtreeGroupObjectType):
return self.circuit_terminations.all()
@strawberry_django.type(
@register_type(
models.VirtualChassis,
fields='__all__',
filters=VirtualChassisFilter,
@ -1016,7 +1019,7 @@ class VirtualChassisType(PrimaryObjectType):
members: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VirtualDeviceContext,
fields='__all__',
filters=VirtualDeviceContextFilter,

View File

@ -0,0 +1,54 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0245_modulebaytype'),
]
operations = [
migrations.AddField(
model_name='interfacetemplate',
name='parent',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name='child_interfaces',
to='dcim.interfacetemplate',
),
),
migrations.AddField(
model_name='interfacetemplate',
name='channel_id',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='interfacetemplate',
name='channels',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddConstraint(
model_name='interfacetemplate',
constraint=models.UniqueConstraint(
fields=('parent', 'channel_id'),
name='dcim_interfacetemplate_unique_parent_channel_id',
),
),
]

View File

@ -0,0 +1,43 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0246_interfacetemplate_channels'),
]
operations = [
migrations.AddField(
model_name='interface',
name='channel_id',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='interface',
name='channels',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddConstraint(
model_name='interface',
constraint=models.UniqueConstraint(
fields=('parent', 'channel_id'),
name='dcim_interface_unique_parent_channel_id',
),
),
]

View File

@ -25,7 +25,7 @@ from netbox.models import ChangeLoggedModel, PrimaryModel
from utilities.conversion import to_meters
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet
from utilities.querysets import RestrictedQuerySet, chunked_update
from utilities.serialization import deserialize_object, serialize_object
from wireless.models import WirelessLink
@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel):
cable_pk=existing_termination.cable.pk
)
)
# A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked
# ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific
# guidance.
if self.termination_type.model == 'interface' and self.termination.channel_id:
raise ValidationError(
_("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.")
)
# Validate the interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(
@ -783,7 +791,7 @@ class CablePath(models.Model):
# Record a direct reference to this CablePath on its originating object(s)
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk)
chunked_update(origin_model.objects.filter(pk__in=origin_ids), _path=self.pk)
def delete(self, *args, **kwargs):
# Mirror save() - clear _path on origins to prevent stale references
@ -791,7 +799,7 @@ class CablePath(models.Model):
if self.path:
origin_model = self.origin_type.model_class()
origin_ids = [decompile_path_node(node)[1] for node in self.path[0]]
origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None)
chunked_update(origin_model.objects.filter(pk__in=origin_ids, _path=self.pk), _path=None)
super().delete(*args, **kwargs)
@ -971,6 +979,12 @@ class CablePath(models.Model):
peer_results = cable_profile.get_peer_terminations(term_position_pairs)
seen = set()
for peer, new_pos in peer_results:
# If the far-end termination is a channelized interface, resolve to the specific channel
# subinterface bound to the mapped connector position (the far end is channelized on the same
# physical connector, so the peer lookup returns the parent rather than the channel). A
# channelized parent is never itself a path endpoint, so an unoccupied position yields no peer.
if new_pos is not None and getattr(peer, 'channels', None):
peer = peer.child_interfaces.filter(channel_id=new_pos).first()
# Deduplicate peer terminations by model type & PK.
key = None if peer is None else (peer._meta.concrete_model, peer.pk)
if key not in seen:

View File

@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
max_length=50,
choices=InterfaceTypeChoices
)
channels = models.PositiveSmallIntegerField(
verbose_name=_('channels'),
blank=True,
null=True,
validators=(
MinValueValidator(INTERFACE_CHANNELS_MIN),
MaxValueValidator(INTERFACE_CHANNELS_MAX)
),
help_text=_('The number of channels into which this interface is channelized')
)
channel_id = models.PositiveSmallIntegerField(
verbose_name=_('channel ID'),
blank=True,
null=True,
validators=(
MinValueValidator(INTERFACE_CHANNELS_MIN),
MaxValueValidator(INTERFACE_CHANNELS_MAX)
),
help_text=_('The channel on the parent interface to which this subinterface is bound')
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True
@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
default=False,
verbose_name=_('management only')
)
parent = models.ForeignKey(
to='self',
on_delete=models.RESTRICT,
related_name='child_interfaces',
null=True,
blank=True,
verbose_name=_('parent interface')
)
bridge = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
component_model = Interface
class Meta(ModularComponentTemplateModel.Meta):
constraints = (
*ModularComponentTemplateModel.Meta.constraints,
models.UniqueConstraint(
fields=('parent', 'channel_id'),
name='%(app_label)s_%(class)s_unique_parent_channel_id'
),
)
verbose_name = _('interface template')
verbose_name_plural = _('interface templates')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count
# reduction that would orphan a bound subinterface.
self._original_channels = self.__dict__.get('channels')
def clean(self):
super().clean()
# Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin
if self.parent:
if self.device_type and self.device_type != self.parent.device_type:
raise ValidationError({
'parent': _(
"Parent interface ({parent}) must belong to the same device type"
).format(parent=self.parent)
})
if self.module_type and self.module_type != self.parent.module_type:
raise ValidationError({
'parent': _(
"Parent interface ({parent}) must belong to the same module type"
).format(parent=self.parent)
})
if self.bridge:
if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
channels=self.channels,
channel_id=self.channel_id,
enabled=self.enabled,
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
return {
'name': self.name,
'type': self.type,
'channels': self.channels,
'channel_id': self.channel_id,
'enabled': self.enabled,
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'parent': self.parent.name if self.parent else None,
'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,

View File

@ -860,6 +860,26 @@ class Interface(
max_length=50,
choices=InterfaceTypeChoices
)
channels = models.PositiveSmallIntegerField(
verbose_name=_('channels'),
blank=True,
null=True,
validators=(
MinValueValidator(INTERFACE_CHANNELS_MIN),
MaxValueValidator(INTERFACE_CHANNELS_MAX)
),
help_text=_('The number of channels into which this interface is channelized')
)
channel_id = models.PositiveSmallIntegerField(
verbose_name=_('channel ID'),
blank=True,
null=True,
validators=(
MinValueValidator(INTERFACE_CHANNELS_MIN),
MaxValueValidator(INTERFACE_CHANNELS_MAX)
),
help_text=_('The channel on the parent interface to which this subinterface is bound')
)
mgmt_only = models.BooleanField(
default=False,
verbose_name=_('management only'),
@ -989,14 +1009,33 @@ class Interface(
)
clone_fields = (
'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
'poe_type', 'vrf',
)
class Meta(ModularComponentModel.Meta):
ordering = ('device', CollateAsChar('_name'))
verbose_name = _('interface')
verbose_name_plural = _('interfaces')
constraints = (
*ModularComponentModel.Meta.constraints,
models.UniqueConstraint(
fields=('parent', 'channel_id'),
name='%(app_label)s_%(class)s_unique_parent_channel_id'
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding
# cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire).
# _original_channels is additionally used by InterfaceValidationMixin.clean() to detect a channel-count
# reduction that would orphan a bound subinterface.
self._original_channels = self.__dict__.get('channels')
self._original_channel_id = self.__dict__.get('channel_id')
self._original_parent_id = self.__dict__.get('parent_id')
def clean(self):
super().clean()
@ -1017,15 +1056,7 @@ class Interface(
)
})
# Parent validation
# An interface cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({'parent': _("An interface cannot be its own parent.")})
# A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
# Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin)
# An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device:
@ -1147,7 +1178,9 @@ class Interface(
@property
def is_wired(self):
return not self.is_virtual and not self.is_wireless
# Excludes virtual, wireless, and channel-type interfaces (channel subinterfaces derive their cable from the
# channelized parent and cannot be cabled directly).
return self.type not in NONCONNECTABLE_IFACE_TYPES
@property
def is_virtual(self):
@ -1165,6 +1198,10 @@ class Interface(
def is_bridge(self):
return self.type == InterfaceTypeChoices.TYPE_BRIDGE
@property
def is_channel(self):
return self.type == InterfaceTypeChoices.TYPE_CHANNEL
@property
def link(self):
return self.cable or self.wireless_link
@ -1192,6 +1229,58 @@ class Interface(
return self.virtual_circuit_termination.peer_terminations
return super().connected_endpoints
def set_cable_termination(self, termination):
super().set_cable_termination(termination)
# A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel
# subinterface (occupying a single position of the shared connector) so that each channel traces independently.
if self.channels:
self.propagate_channel_cables()
def clear_cable_termination(self, termination):
super().clear_cable_termination(termination)
if self.channels:
self.clear_channel_cables()
def propagate_channel_cables(self):
"""
Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
child to the single connector position identified by its channel_id. Only profiled cables map connector
positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored.
"""
# Only a profiled cable defines the connector positions that channels map onto; without one, clear any
# previously-mirrored attributes rather than propagate an unusable cable reference.
if not (self.cable and self.cable.profile):
self.clear_channel_cables()
return
# Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a
# per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely.
children = list(self.child_interfaces.filter(channel_id__isnull=False))
for child in children:
child.cable = self.cable
child.cable_end = self.cable_end
child.cable_connector = self.cable_connector
child.cable_positions = [child.channel_id]
type(self).objects.bulk_update(
children, ['cable', 'cable_end', 'cable_connector', 'cable_positions']
)
def clear_channel_cables(self):
"""
Clear the mirrored cable attributes from this channelized interface's channel subinterfaces.
"""
# A queryset update() clears every child in a single query and bypasses the post_save signal (see above).
# cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see
# nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals).
self.child_interfaces.filter(channel_id__isnull=False).update(
cable=None,
cable_end='',
cable_connector=None,
cable_positions=None,
)
#
# Pass-through ports

View File

@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
from dcim.utils import create_port_mappings, update_interface_bridges
from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
@ -1070,7 +1070,9 @@ class Device(
self._instantiate_components(self.device_type.devicebaytemplates.all())
# Disable bulk_create to accommodate MPTT
self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
# Interface bridges have to be set after interface instantiation
# Interface parents & bridges have to be set after interface instantiation. Parents are applied first so
# that channel subinterfaces validate against a populated parent.
update_interface_parents(self, self.device_type.interfacetemplates.all())
update_interface_bridges(self, self.device_type.interfacetemplates.all())
# Update Site and Rack assignment for any child Devices

View File

@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
from dcim.choices import InterfaceTypeChoices
from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
__all__ = (
'CachedScopeMixin',
@ -130,6 +131,81 @@ class InterfaceValidationMixin:
def clean(self):
super().clean()
# An interface cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({'parent': _("An interface cannot be its own parent.")})
# Only virtual and channel interfaces may have a parent interface
if self.parent_id and self.type not in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_CHANNEL):
raise ValidationError({
'parent': _("Only virtual and channel interfaces may be assigned to a parent interface.")
})
# Only one layer of channelization is permitted: an interface cannot be both channelized and a channel
if self.channels and self.channel_id:
raise ValidationError(
_("An interface cannot be both channelized and bound to a channel on a parent interface.")
)
# Only physical interfaces may be channelized
if self.channels and self.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'channels': _("{display_type} interfaces cannot be channelized.").format(
display_type=self.get_type_display()
)
})
# The channel type and channel_id are mutually dependent. The channel_id requirement is relaxed for a
# replication base (bulk creation), where each channel_id is supplied per-instance during expansion.
is_channel = self.type == InterfaceTypeChoices.TYPE_CHANNEL
if is_channel and self.channel_id is None and not getattr(self, '_replicated_base', False):
raise ValidationError({
'channel_id': _("Channel interfaces must have a channel ID assigned.")
})
if self.channel_id is not None and not is_channel:
raise ValidationError({
'channel_id': _("A channel ID can be assigned only to a channel-type interface.")
})
# A channel subinterface must be bound to a channelized parent interface
if is_channel:
if self.parent is None:
raise ValidationError({
'parent': _("Channel interfaces must be assigned to a parent interface.")
})
if not self.parent.channels:
raise ValidationError({
'parent': _("The parent interface ({interface}) is not channelized.").format(
interface=self.parent
)
})
if self.channel_id and self.channel_id > self.parent.channels:
raise ValidationError({
'channel_id': _(
"Invalid channel ID ({channel_id}): the parent interface provides only {channels} channels."
).format(channel_id=self.channel_id, channels=self.parent.channels)
})
# Reducing or clearing the channel count cannot orphan an existing channel subinterface bound to a higher
# channel (clearing channelization entirely would orphan every bound subinterface). Gated on the current or
# original channel count so the child lookup stays off the hot path for ordinary (never-channelized) interfaces.
if self.pk and (self.channels or self._original_channels):
max_child_channel_id = self.child_interfaces.filter(
channel_id__gt=self.channels or 0
).aggregate(models.Max('channel_id'))['channel_id__max']
if max_child_channel_id is not None:
if self.channels:
message = _(
"Cannot set channels to {channels}: a channel subinterface is bound to channel "
"{channel_id}. Delete or reassign the affected subinterface(s) first."
).format(channels=self.channels, channel_id=max_child_channel_id)
else:
message = _(
"Cannot remove channelization: a channel subinterface is bound to channel {channel_id}. "
"Delete or reassign the affected subinterface(s) first."
).format(channel_id=max_child_channel_id)
raise ValidationError({'channels': message})
# An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})

View File

@ -17,6 +17,7 @@ from dcim.utils import (
)
from utilities.counters import update_counter
from utilities.exceptions import AbortRequest
from utilities.querysets import chunked_update
from .device_components import (
ConsolePort,
@ -825,10 +826,13 @@ class ModuleMovePlan:
moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
if moved_front_port_pks and moved_rear_port_pks:
PortMapping.objects.filter(
front_port_id__in=moved_front_port_pks,
rear_port_id__in=moved_rear_port_pks,
).update(device_id=self.new_device_id)
chunked_update(
PortMapping.objects.filter(
front_port_id__in=moved_front_port_pks,
rear_port_id__in=moved_rear_port_pks,
),
device_id=self.new_device_id,
)
def _recompute_counters(self):
# bulk updates bypass the signal-driven counters; apply exact deltas for both devices

View File

@ -2,6 +2,7 @@ from collections.abc import Iterable, Mapping
import jsonschema
import yaml
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import OperationalError, models, router, transaction
from django.db.models.signals import post_save
@ -9,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
from dcim.choices import *
from dcim.utils import create_port_mappings, update_interface_bridges
from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
from extras.models import CustomField
from netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin
@ -577,7 +578,9 @@ class Module(TrackingModelMixin, PrimaryModel):
instance.parent = self.module_bay
update_fields = ['module', 'parent']
component_model.objects.bulk_update(update_instances, update_fields)
component_model.objects.bulk_update(
update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
)
for component in update_instances:
post_save.send(
sender=component_model,
@ -591,7 +594,9 @@ class Module(TrackingModelMixin, PrimaryModel):
# Replicate any front/rear port mappings from the ModuleType
create_port_mappings(self.device, self.module_type, self)
# Interface bridges have to be set after interface instantiation
# Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that
# channel subinterfaces validate against a populated parent.
update_interface_parents(self.device, self.module_type.interfacetemplates, self)
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
def _save_existing(self, *args, **kwargs):

View File

@ -6,6 +6,7 @@ from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from netbox.search.backends import search_backend
from utilities.querysets import chunked_update
from virtualization.models import VMInterface
from .models import (
@ -23,7 +24,7 @@ from .models import (
)
from .models.cables import trace_paths
from .search import DeviceIndex
from .utils import create_cablepaths, rebuild_paths
from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
#
# Location/rack/device assignment
@ -37,11 +38,11 @@ def handle_location_site_change(instance, created, **kwargs):
(and to descendant Locations).
"""
if not created:
instance.get_descendants().update(site=instance.site)
chunked_update(instance.get_descendants(), site=instance.site)
locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
chunked_update(Rack.objects.filter(location__in=locations), site=instance.site)
chunked_update(Device.objects.filter(location__in=locations), site=instance.site)
chunked_update(PowerPanel.objects.filter(location__in=locations), site=instance.site)
@receiver(post_save, sender=Rack)
@ -50,7 +51,7 @@ def handle_rack_site_change(instance, created, **kwargs):
Cascade a Rack's Site/Location assignment down to the Devices it contains.
"""
if not created:
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
chunked_update(Device.objects.filter(rack=instance), site=instance.site, location=instance.location)
#
@ -123,7 +124,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
# Update status of CablePaths if Cable status has been changed
elif instance.status != instance._orig_status:
if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
chunked_update(CablePath.objects.filter(_nodes__contains=instance), is_active=False)
else:
rebuild_paths([instance])
@ -156,6 +157,14 @@ def nullify_connected_endpoints(instance, **kwargs):
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
# If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
# subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down
# rather than rebuilt from a stale cable reference.
if model is Interface:
Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update(
cable=None, cable_end='', cable_connector=None, cable_positions=None
)
# If the parent Cable is being deleted in this same operation, skip the
# per-termination retrace; retrace_cable_paths() will retrace each affected
# path once after the Cable is deleted.
@ -171,6 +180,66 @@ def nullify_connected_endpoints(instance, **kwargs):
cablepath.retrace()
@receiver(post_save, sender=Interface)
def update_channelized_cable_paths(instance, created, raw=False, **kwargs):
"""
Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel
subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an
already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.)
"""
if raw:
return
parent_ids = set()
# A channel subinterface was added, moved between parents, or had its channel_id changed
if instance.channel_id or instance._original_channel_id:
parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk)
# Channelization was toggled on this interface while it carries a cable
if instance.channels != instance._original_channels and instance.cable_id:
parent_ids.add(instance.pk)
# select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both
# propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a
# relation, so it needs no prefetching.)
parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable')
for parent in parents:
if parent.channels:
parent.propagate_channel_cables()
rebuild_cable_paths(parent.cable)
# A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable attributes
if instance.channel_id and instance.cable_id:
parent = instance.parent
if not (parent and parent.channels and parent.cable_id):
Interface.objects.filter(pk=instance.pk).update(
cable=None, cable_end='', cable_connector=None, cable_positions=None
)
for cablepath in CablePath.objects.filter(_nodes__contains=instance):
if instance in cablepath.origins:
cablepath.delete()
# Refresh the cached channelization state so that saving this same in-memory instance again compares against its
# current values rather than re-triggering propagation from a stale baseline.
instance._original_channels = instance.channels
instance._original_channel_id = instance.channel_id
instance._original_parent_id = instance.parent_id
@receiver(post_delete, sender=Interface)
def cleanup_channel_subinterface_paths(instance, **kwargs):
"""
When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path
is torn down.
"""
if instance.channel_id and instance.parent_id:
parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first()
if parent and parent.channels:
parent.propagate_channel_cables()
rebuild_cable_paths(parent.cable)
@receiver(post_save, sender=Interface)
@receiver(post_save, sender=VMInterface)
def update_mac_address_interface(instance, created, raw, **kwargs):

View File

@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15
CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
def _cable_side_nodes(term_nodes, cable_terminations):
"""
Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does
not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries
the actual CableTermination, so it is matched via its parent.
"""
def matches(obj):
if obj in cable_terminations:
return True
if getattr(obj, 'channel_id', None):
return obj.parent in cable_terminations
return False
return [node for node in term_nodes if matches(node.object)]
class Node(Hyperlink):
"""
Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
@ -380,13 +396,14 @@ class CableTraceSVG:
description.append(f"{cable.length} {cable.get_length_unit_display()}")
color = cable.color or '000000'
# Collect all connected nodes to this cable
near = [term for term in near_terminations if term.object in cable.a_terminations]
far = [term for term in far_terminations if term.object in cable.b_terminations]
# Collect all connected nodes to this cable. Channel subinterfaces are matched via their
# parent interface, which carries the actual cable termination.
near = _cable_side_nodes(near_terminations, cable.a_terminations)
far = _cable_side_nodes(far_terminations, cable.b_terminations)
if not (near and far):
# a and b terminations may be swapped
near = [term for term in near_terminations if term.object in cable.b_terminations]
far = [term for term in far_terminations if term.object in cable.a_terminations]
near = _cable_side_nodes(near_terminations, cable.b_terminations)
far = _cable_side_nodes(far_terminations, cable.a_terminations)
elif isinstance(cable, WirelessLink):
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid:

View File

@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
class Meta(DeviceComponentTable.Meta):
model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels',
'channel_id', 'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses',
'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
'vlan_translation_policy',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = models.InterfaceTemplate
fields = (
'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type',
'rf_role', 'actions',
'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent',
'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions',
)
empty_text = "None"

View File

@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
interface_templates = (
InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'),
# Interface Template 3 is channelized, so that channel subinterface templates may be bound to it
InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4),
)
InterfaceTemplate.objects.bulk_create(interface_templates)
channelized_parent = interface_templates[2]
cls.create_data = [
{
@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
'name': 'Interface Template 7',
'type': '1000base-t',
},
{
# A channelized parent template
'device_type': devicetype.pk,
'name': 'Interface Template 8',
'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
'channels': 4,
},
{
# A channel subinterface template bound to a channelized parent
'device_type': devicetype.pk,
'name': 'Interface Template 9',
'type': InterfaceTypeChoices.TYPE_CHANNEL,
'parent': channelized_parent.pk,
'channel_id': 1,
},
]
@ -2923,9 +2940,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
interfaces = (
Interface(device=device, name='Interface 1', type='1000base-t'),
Interface(device=device, name='Interface 2', type='1000base-t'),
Interface(device=device, name='Interface 3', type='1000base-t'),
# Interface 3 is channelized, so that channel subinterfaces may be bound to it
Interface(device=device, name='Interface 3', type='1000base-t', channels=4),
)
Interface.objects.bulk_create(interfaces)
channelized_parent = interfaces[2]
vdcs = (
VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
'rf_channel': "",
'qinq_svlan': vlans[3].pk,
},
{
# A channelized parent interface
'device': device.pk,
'name': 'Interface 9',
'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
'channels': 4,
},
{
# A channel subinterface bound to a channelized parent
'device': device.pk,
'name': 'Interface 10',
'type': InterfaceTypeChoices.TYPE_CHANNEL,
'parent': channelized_parent.pk,
'channel_id': 1,
},
]
def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):

View File

@ -0,0 +1,630 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.urls import reverse
from dcim.choices import CableProfileChoices, InterfaceTypeChoices
from dcim.models import (
Cable,
CablePath,
Device,
DeviceRole,
DeviceType,
Interface,
InterfaceTemplate,
Manufacturer,
Site,
)
from dcim.svg import CableTraceSVG
from dcim.svg.cables import Connector
from dcim.tests.utils import BaseCablePathTestCase
from utilities.testing import TestCase as ViewTestCase
class ChannelizedCablePathTestCase(BaseCablePathTestCase):
"""
Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent)
interface, and each of the parent's channel subinterfaces traces an independent path from the connector position
identified by its channel_id.
"""
def _create_channelized_interface(self, name, channels, device=None):
"""Create a channelized parent interface and its channel subinterfaces."""
device = device or self.device
parent = Interface.objects.create(
device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels
)
children = [
Interface.objects.create(
device=device,
name=f'{name}:{i}',
type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=parent,
channel_id=i,
)
for i in range(1, channels + 1)
]
return parent, children
def test_101_channelized_breakout_to_discrete_interfaces(self):
"""
A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel
subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
# One forward and one reverse path per channel; the parent originates no path
self.assertEqual(CablePath.objects.count(), 8)
parent.refresh_from_db()
self.assertPathIsNotSet(parent)
for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
channel.refresh_from_db()
far_iface.refresh_from_db()
# The parent's cable is mirrored onto the channel, restricted to its single connector position
self.assertEqual(channel.cable_id, cable.pk)
self.assertEqual(channel.cable_connector, 1)
self.assertEqual(channel.cable_positions, [i])
forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True)
self.assertPathIsSet(channel, forward)
self.assertPathIsSet(far_iface, reverse)
# The trace SVG must render from both a channel subinterface and a discrete far-end interface
CableTraceSVG(channels[0]).render()
CableTraceSVG(far[0]).render()
def test_102_channelized_to_channelized(self):
"""
Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each
near-end channel traces to the far-end channel bound to the same position.
"""
near_parent, near_channels = self._create_channelized_interface('et0', 4)
far_device = Device.objects.create(
site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2'
)
far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device)
cable = Cable(
profile=CableProfileChoices.SINGLE_1C4P,
a_terminations=[near_parent],
b_terminations=[far_parent],
)
cable.clean()
cable.save()
self.assertEqual(CablePath.objects.count(), 8)
for near, far in zip(near_channels, far_channels):
near.refresh_from_db()
far.refresh_from_db()
self.assertPathExists((near, cable, far), is_complete=True, is_active=True)
self.assertPathExists((far, cable, near), is_complete=True, is_active=True)
# The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable
# terminates on the parent interfaces, so the connector is matched to the channels via their parents.
svg = CableTraceSVG(near_channels[0])
svg.render()
self.assertTrue(
any(isinstance(c, Connector) for c in svg.connectors),
msg="Trace SVG did not render a cable connector for the channelized path"
)
def test_103_add_channel_after_cabling(self):
"""
On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel
subinterface (re-adding one on the freed position) builds a fresh path for it in both directions.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
# Removing the fourth channel tears down its complete path in both directions
channels[3].delete()
self.assertPathDoesNotExist((channels[3], cable, far[3]))
self.assertPathDoesNotExist((far[3], cable, channels[3]))
# Re-adding a channel on position 4 restores the complete path in both directions
new_channel = Interface.objects.create(
device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
)
new_channel.refresh_from_db()
self.assertEqual(new_channel.cable_positions, [4])
self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True)
self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True)
def test_104_change_channel_id(self):
"""
Changing a channel's channel_id re-binds it to a different connector position, in both directions.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
# Delete channels 3 and 4 so their positions are free to reassign to
channels[2].delete()
channels[3].delete()
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
# Channel 1 initially traces to far[0]
self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True)
# Move channel 1 to position 3
channels[0].channel_id = 3
channels[0].save()
channels[0].refresh_from_db()
self.assertEqual(channels[0].cable_positions, [3])
self.assertPathDoesNotExist((channels[0], cable, far[0]))
self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True)
self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True)
def test_105_incomplete_channel(self):
"""
A channel whose position has no far-end termination yields an incomplete path (rather than an error).
"""
parent, channels = self._create_channelized_interface('et0', 4)
# Only two far-end interfaces exist, on connectors 1 and 2
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(2)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
# Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path
channels[0].refresh_from_db()
channels[2].refresh_from_db()
self.assertPathExists((channels[0], cable, far[0]), is_complete=True)
self.assertIsNotNone(channels[2]._path_id)
self.assertFalse(channels[2].path.is_complete)
def test_106_cable_removal_teardown(self):
"""
Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable
attributes from the channels.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
self.assertEqual(CablePath.objects.count(), 8)
cable.delete()
self.assertEqual(CablePath.objects.count(), 0)
for channel in channels:
channel.refresh_from_db()
self.assertIsNone(channel.cable_id)
self.assertIsNone(channel.cable_connector)
self.assertIsNone(channel.cable_positions)
self.assertPathIsNotSet(channel)
def test_107_direct_cabling_of_channel_rejected(self):
"""
A cable cannot be terminated directly to a channel subinterface.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = Interface.objects.create(
device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
cable = Cable(a_terminations=[channels[0]], b_terminations=[far])
with self.assertRaises(ValidationError):
cable.clean()
def test_108_unprofiled_cable_not_propagated(self):
"""
An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's
channel subinterfaces.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = Interface.objects.create(
device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
cable = Cable(a_terminations=[parent], b_terminations=[far])
cable.clean()
cable.save()
# The parent itself is cabled, but no cable attributes are mirrored onto the channels
parent.refresh_from_db()
self.assertEqual(parent.cable_id, cable.pk)
for channel in channels:
channel.refresh_from_db()
self.assertIsNone(channel.cable_id)
self.assertIsNone(channel.cable_positions)
def test_109_change_channel_count_after_cabling(self):
"""
Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel
subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires).
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
self.assertEqual(CablePath.objects.count(), 8)
# Increase the channel count; the existing channels' paths must survive
parent.refresh_from_db()
parent.channels = 8
parent.save()
self.assertEqual(CablePath.objects.count(), 8)
for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
channel.refresh_from_db()
self.assertEqual(channel.cable_positions, [i])
self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
def test_110_move_channel_to_uncabled_parent(self):
"""
Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the
channel's mirrored cable attributes and its (now orphaned) path.
"""
parent, channels = self._create_channelized_interface('et0', 4)
far = [
Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
for i in range(4)
]
cable = Cable(
profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
a_terminations=[parent],
b_terminations=far,
)
cable.clean()
cable.save()
# A second channelized parent with no cable
uncabled_parent = Interface.objects.create(
device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
)
# Move the first channel to the uncabled parent; its mirrored cable & path must be torn down
channel = channels[0]
channel.refresh_from_db()
self.assertEqual(channel.cable_id, cable.pk)
channel.parent = uncabled_parent
channel.save()
channel.refresh_from_db()
self.assertIsNone(channel.cable_id)
self.assertIsNone(channel.cable_connector)
self.assertIsNone(channel.cable_positions)
self.assertPathIsNotSet(channel)
self.assertPathDoesNotExist((channel, cable, far[0]))
self.assertPathDoesNotExist((far[0], cable, channel))
class ChannelizedInterfaceValidationTestCase(TestCase):
"""
Test validation of the channels and channel_id fields on Interface.
"""
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
site = Site.objects.create(name='Site', slug='site')
cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1')
cls.parent = Interface.objects.create(
device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
)
def test_valid_channel_subinterface(self):
interface = Interface(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
)
interface.full_clean() # Should not raise
def test_channel_type_requires_channel_id(self):
interface = Interface(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channel_id_requires_channel_type(self):
interface = Interface(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
parent=self.parent, channel_id=1
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channel_requires_parent(self):
interface = Interface(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channel_requires_channelized_parent(self):
plain_parent = Interface.objects.create(
device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
interface = Interface(
device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channel_id_within_parent_range(self):
interface = Interface(
device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channels_and_channel_id_mutually_exclusive(self):
interface = Interface(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=self.parent, channel_id=1, channels=4
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_channels_not_allowed_on_virtual_type(self):
interface = Interface(
device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4
)
with self.assertRaises(ValidationError):
interface.full_clean()
def test_reduce_channels_below_bound_child_rejected(self):
# Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count
Interface.objects.create(
device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4
)
self.parent.channels = 2
with self.assertRaises(ValidationError):
self.parent.full_clean()
def test_clear_channels_with_bound_child_rejected(self):
# De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it
Interface.objects.create(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
)
self.parent.channels = None
with self.assertRaises(ValidationError):
self.parent.full_clean()
def test_clear_channels_without_bound_child_allowed(self):
# De-channelizing is permitted once no channel subinterfaces remain bound to the parent
self.parent.channels = None
self.parent.full_clean() # Should not raise
def test_parent_channel_id_must_be_unique(self):
Interface.objects.create(
device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
)
duplicate = Interface(
device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
)
with self.assertRaises(ValidationError):
duplicate.full_clean()
class ChannelizedInterfaceTemplateTestCase(TestCase):
"""
Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces
instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces.
"""
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role')
cls.site = Site.objects.create(name='Site', slug='site')
# A channelized parent template broken out into four channel subinterface templates bound to it
parent_template = InterfaceTemplate.objects.create(
device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
)
for i in range(1, 5):
InterfaceTemplate.objects.create(
device_type=cls.device_type,
name=f'et0:{i}',
type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=parent_template,
channel_id=i,
)
def test_channelization_replicated_on_instantiation(self):
device = Device.objects.create(
site=self.site, device_type=self.device_type, role=self.role, name='Device 1'
)
# The channelized parent carries its channel count
parent = device.interfaces.get(name='et0')
self.assertEqual(parent.channels, 4)
self.assertIsNone(parent.channel_id)
# Each channel subinterface carries its channel ID and is bound to the instantiated parent interface
for i in range(1, 5):
channel = device.interfaces.get(name=f'et0:{i}')
self.assertEqual(channel.channel_id, i)
self.assertIsNone(channel.channels)
self.assertEqual(channel.parent, parent)
def test_parent_template_validation(self):
# A parent template must belong to the same device type
other_type = DeviceType.objects.create(
manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device'
)
foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
template = InterfaceTemplate(
device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=foreign_parent, channel_id=1
)
with self.assertRaises(ValidationError):
template.full_clean()
def test_template_parent_channel_id_must_be_unique(self):
parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
# Channel 1 already exists on the parent (created in setUpTestData)
duplicate = InterfaceTemplate(
device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=parent, channel_id=1
)
with self.assertRaises(ValidationError):
duplicate.full_clean()
def test_template_channel_id_within_parent_range(self):
# A channel_id beyond the parent's channel count is rejected at the template level
parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
template = InterfaceTemplate(
device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=parent, channel_id=5
)
with self.assertRaises(ValidationError):
template.full_clean()
def test_template_channel_requires_channelized_parent(self):
# A channel template bound to a non-channelized parent template is rejected
plain_parent = InterfaceTemplate.objects.create(
device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
template = InterfaceTemplate(
device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
parent=plain_parent, channel_id=1
)
with self.assertRaises(ValidationError):
template.full_clean()
def test_template_channel_id_requires_channel_type(self):
# A channel_id on a non-channel-type template is rejected
template = InterfaceTemplate(
device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
channel_id=1
)
with self.assertRaises(ValidationError):
template.full_clean()
def test_template_reduce_channels_below_bound_child_rejected(self):
# Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels
# 3 & 4 are bound in setUpTestData)
parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
parent.channels = 2
with self.assertRaises(ValidationError):
parent.full_clean()
def test_template_clear_channels_with_bound_child_rejected(self):
# De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it
parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
parent.channels = None
with self.assertRaises(ValidationError):
parent.full_clean()
class ChannelizedBulkCreateTestCase(ViewTestCase):
"""
Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each
generated object receives a distinct channel_id.
"""
def setUp(self):
super().setUp()
manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
self.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device', slug='test-device'
)
role = DeviceRole.objects.create(name='Device Role', slug='device-role')
site = Site.objects.create(name='Site', slug='site')
self.device = Device.objects.create(
site=site, device_type=self.device_type, role=role, name='Device 1'
)
def test_bulk_create_channel_subinterfaces(self):
parent = Interface.objects.create(
device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
)
self.add_permissions('dcim.add_interface', 'dcim.view_interface')
request_data = {
'device': self.device.pk,
'name': 'et0:[1-4]',
'type': InterfaceTypeChoices.TYPE_CHANNEL,
'parent': parent.pk,
'channel_id': '[1-4]',
}
response = self.client.post(reverse('dcim:interface_add'), request_data)
self.assertHttpStatus(response, 302)
# Four channel subinterfaces are created, each bound to a distinct channel on the parent
channels = Interface.objects.filter(parent=parent).order_by('channel_id')
self.assertEqual(channels.count(), 4)
for i, channel in enumerate(channels, start=1):
self.assertEqual(channel.name, f'et0:{i}')
self.assertEqual(channel.channel_id, i)
def test_bulk_create_channel_subinterface_templates(self):
parent = InterfaceTemplate.objects.create(
device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
)
self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
request_data = {
'device_type': self.device_type.pk,
'name': 'et0:[1-4]',
'type': InterfaceTypeChoices.TYPE_CHANNEL,
'parent': parent.pk,
'channel_id': '[1-4]',
}
response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data)
self.assertHttpStatus(response, 302)
templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id')
self.assertEqual(templates.count(), 4)
for i, template in enumerate(templates, start=1):
self.assertEqual(template.name, f'et0:{i}')
self.assertEqual(template.channel_id, i)

View File

@ -523,6 +523,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
channels = attrs.NumericAttr('channels')
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
duplex = attrs.ChoiceAttr('duplex')
mtu = attrs.NumericAttr('mtu', label=_('MTU'))
@ -543,6 +544,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
title = _('Related Interfaces')
parent = attrs.RelatedObjectAttr('parent', linkify=True)
channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID'))
bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))

View File

@ -131,12 +131,29 @@ def create_cablepaths(objects):
:param objects: Iterable of cabled objects (e.g. Interfaces)
"""
from dcim.models import CablePath
from dcim.models import CablePath, Interface
# Arrange objects by cable connector. All objects with a null connector are grouped together.
origins = defaultdict(list)
# Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
# own; instead, each channel subinterface traces independently from the single connector position it occupies.
# Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
# rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
expanded = []
for obj in objects:
origins[obj.cable_connector].append(obj)
if isinstance(obj, Interface) and obj.channels:
expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
else:
expanded.append(obj)
# Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
# subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
# siblings into a single malformed path.
origins = defaultdict(list)
for obj in expanded:
if isinstance(obj, Interface) and obj.channel_id:
if cp := CablePath.from_origin([obj]):
cp.save()
else:
origins[obj.cable_connector].append(obj)
for connector, objects in origins.items():
if cp := CablePath.from_origin(objects):
@ -158,6 +175,54 @@ def rebuild_paths(terminations):
create_cablepaths(cp.origins)
def rebuild_cable_paths(cable):
"""
Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
subinterface is added, moved, or removed) without the Cable itself being modified.
"""
from dcim.choices import CableEndChoices
from dcim.models import CablePath, CableTermination, PathEndpoint
with transaction.atomic(using=router.db_for_write(CablePath)):
# Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
for cp in CablePath.objects.filter(_nodes__contains=cable):
cp.delete()
a_terminations, b_terminations = [], []
for ct in CableTermination.objects.filter(cable=cable):
if ct.cable_end == CableEndChoices.SIDE_A:
a_terminations.append(ct.termination)
else:
b_terminations.append(ct.termination)
for nodes in (a_terminations, b_terminations):
if not nodes:
continue
if isinstance(nodes[0], PathEndpoint):
create_cablepaths(nodes)
else:
rebuild_paths(nodes)
def update_interface_parents(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
"""
Interface = apps.get_model('dcim', 'Interface')
for interface_template in interface_templates.exclude(parent=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
interface.parent = Interface.objects.get(
device=device,
name=interface_template.parent.resolve_name(module=module)
)
interface.full_clean()
interface.save()
def update_interface_bridges(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned

View File

@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@ -8,6 +9,7 @@ from extras.choices import CustomFieldTypeChoices
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
from extras.models import CustomField
from utilities.api import get_serializer_for_model
from utilities.forms.fields import LaxURLField
#
# Custom fields
@ -120,6 +122,15 @@ class CustomFieldsDataField(Field):
else:
raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
# Normalize URL values the same way the UI does (LaxURLField with assume_scheme='https'), so a
# schemeless value (e.g. "example.com") is stored as an absolute URL ("https://example.com").
# Malformed values are left untouched for CustomField.validate() to report.
elif cf.type == CustomFieldTypeChoices.TYPE_URL and isinstance(data.get(cf.name), str) and data[cf.name]:
try:
data[cf.name] = LaxURLField(assume_scheme='https').to_python(data[cf.name])
except DjangoValidationError:
pass
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data}

View File

@ -12,6 +12,7 @@ from django.db.models import F, Q
from dcim.models import Device
from extras.jobs import RenderConfigContextJob
from extras.models.tags import TaggedItem
from utilities.querysets import chunked_update
from virtualization.models import VirtualMachine
@ -30,7 +31,8 @@ def invalidate_config_context_for_objects(model_label, pks):
return
Model = apps.get_model(model_label)
updated = Model.objects.filter(pk__in=pks).update(
updated = chunked_update(
Model.objects.filter(pk__in=pks),
_config_context_data=None,
_config_context_generation=F('_config_context_generation') + 1,
)

View File

@ -6,14 +6,6 @@ from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# Maximum number of objects to update per query when provisioning, removing, or renaming custom
# field data. Bounding the number of rows touched by each statement prevents very large tables from
# exceeding the database statement timeout (JSONB updates rewrite each affected row). This value
# sits at the throughput "knee": benchmarking jsonb_set() across a 1M-row table showed throughput
# plateaus by ~5K rows/statement (raising it further yields no meaningful speedup), while keeping
# each statement orders of magnitude below a typical statement timeout.
CUSTOMFIELD_DATA_BATCH_SIZE = 5000
# ImageAttachment
IMAGE_ATTACHMENT_IMAGE_FORMATS = {
'avif': 'image/avif',

View File

@ -9,7 +9,7 @@ from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLook
from extras import models
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
from netbox.graphql.filter_mixins import SyncedDataFilterMixin
from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter
from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter, register_filter
if TYPE_CHECKING:
from core.graphql.filters import ContentTypeFilter
@ -59,7 +59,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.ConfigContext, lookups=True)
@register_filter(models.ConfigContext, lookups=True)
class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@ -116,14 +116,14 @@ class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
)
@strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
@register_filter(models.ConfigContextProfile, lookups=True)
class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
@register_filter(models.ConfigTemplate, lookups=True)
class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -137,7 +137,7 @@ class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.CustomField, lookups=True)
@register_filter(models.CustomField, lookups=True)
class CustomFieldFilter(ChangeLoggedModelFilter):
type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
strawberry_django.filter_field()
@ -197,7 +197,7 @@ class CustomFieldFilter(ChangeLoggedModelFilter):
comments: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
@register_filter(models.CustomFieldChoiceSet, lookups=True)
class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -239,7 +239,7 @@ class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
return queryset, params
@strawberry_django.filter_type(models.CustomLink, lookups=True)
@register_filter(models.CustomLink, lookups=True)
class CustomLinkFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
@ -257,7 +257,7 @@ class CustomLinkFilter(ChangeLoggedModelFilter):
new_window: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ExportTemplate, lookups=True)
@register_filter(models.ExportTemplate, lookups=True)
class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -271,7 +271,7 @@ class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ImageAttachment, lookups=True)
@register_filter(models.ImageAttachment, lookups=True)
class ImageAttachmentFilter(ChangeLoggedModelFilter):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -289,7 +289,7 @@ class ImageAttachmentFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.JournalEntry, lookups=True)
@register_filter(models.JournalEntry, lookups=True)
class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -305,7 +305,7 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
comments: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Notification, lookups=True)
@register_filter(models.Notification, lookups=True)
class NotificationFilter(BaseModelFilter):
created: DatetimeFilterLookup | None = strawberry_django.filter_field()
read: DatetimeFilterLookup | None = strawberry_django.filter_field()
@ -320,7 +320,7 @@ class NotificationFilter(BaseModelFilter):
event_type: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.NotificationGroup, lookups=True)
@register_filter(models.NotificationGroup, lookups=True)
class NotificationGroupFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -328,7 +328,7 @@ class NotificationGroupFilter(ChangeLoggedModelFilter):
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.SavedFilter, lookups=True)
@register_filter(models.SavedFilter, lookups=True)
class SavedFilterFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -345,7 +345,7 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
)
@strawberry_django.filter_type(models.Subscription, lookups=True)
@register_filter(models.Subscription, lookups=True)
class SubscriptionFilter(BaseModelFilter):
created: DatetimeFilterLookup | None = strawberry_django.filter_field()
user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@ -357,7 +357,7 @@ class SubscriptionFilter(BaseModelFilter):
object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.TableConfig, lookups=True)
@register_filter(models.TableConfig, lookups=True)
class TableConfigFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -370,7 +370,7 @@ class TableConfigFilter(ChangeLoggedModelFilter):
shared: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Tag, lookups=True)
@register_filter(models.Tag, lookups=True)
class TagFilter(ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -380,7 +380,7 @@ class TagFilter(ChangeLoggedModelFilter):
description: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Webhook, lookups=True)
@register_filter(models.Webhook, lookups=True)
class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -402,7 +402,7 @@ class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelF
)
@strawberry_django.filter_type(models.EventRule, lookups=True)
@register_filter(models.EventRule, lookups=True)
class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()

View File

@ -1,14 +1,13 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import JSON
from strawberry.types import Info
from core.graphql.mixins import SyncedDataMixin
from extras import models
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType
from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, PrimaryObjectType, register_type
from users.graphql.mixins import OwnerMixin
from .filters import *
@ -60,7 +59,7 @@ class SharedObjectMixin:
return queryset.restrict_to_shared(info.context.request.user)
@strawberry_django.type(
@register_type(
models.ConfigContextProfile,
fields='__all__',
filters=ConfigContextProfileFilter,
@ -70,7 +69,7 @@ class ConfigContextProfileType(SyncedDataMixin, PrimaryObjectType):
pass
@strawberry_django.type(
@register_type(
models.ConfigContext,
fields='__all__',
filters=ConfigContextFilter,
@ -93,7 +92,7 @@ class ConfigContextType(SyncedDataMixin, OwnerMixin, ObjectType):
site_groups: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ConfigTemplate,
fields='__all__',
filters=ConfigTemplateFilter,
@ -106,7 +105,7 @@ class ConfigTemplateType(SyncedDataMixin, OwnerMixin, TagsMixin, ObjectType):
device_roles: list[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.CustomField,
fields='__all__',
filters=CustomFieldFilter,
@ -117,7 +116,7 @@ class CustomFieldType(OwnerMixin, ObjectType):
choice_set: Annotated["CustomFieldChoiceSetType", strawberry.lazy('extras.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.CustomFieldChoiceSet,
exclude=['extra_choices', 'choice_colors'],
filters=CustomFieldChoiceSetFilter,
@ -130,7 +129,7 @@ class CustomFieldChoiceSetType(OwnerMixin, ObjectType):
choice_colors: JSON
@strawberry_django.type(
@register_type(
models.CustomLink,
fields='__all__',
filters=CustomLinkFilter,
@ -140,7 +139,7 @@ class CustomLinkType(OwnerMixin, ObjectType):
pass
@strawberry_django.type(
@register_type(
models.ExportTemplate,
fields='__all__',
filters=ExportTemplateFilter,
@ -150,7 +149,7 @@ class ExportTemplateType(SyncedDataMixin, OwnerMixin, ObjectType):
pass
@strawberry_django.type(
@register_type(
models.ImageAttachment,
fields='__all__',
filters=ImageAttachmentFilter,
@ -160,7 +159,7 @@ class ImageAttachmentType(BaseObjectType):
object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.JournalEntry,
fields='__all__',
filters=JournalEntryFilter,
@ -171,7 +170,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Notification,
filters=NotificationFilter,
pagination=True
@ -180,7 +179,7 @@ class NotificationType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.NotificationGroup,
filters=NotificationGroupFilter,
pagination=True
@ -190,7 +189,7 @@ class NotificationGroupType(ObjectType):
groups: list[Annotated["GroupType", strawberry.lazy('users.graphql.types')]]
@strawberry_django.type(
@register_type(
models.SavedFilter,
exclude=['content_types',],
filters=SavedFilterFilter,
@ -200,7 +199,7 @@ class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Subscription,
filters=SubscriptionFilter,
pagination=True
@ -209,7 +208,7 @@ class SubscriptionType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.TableConfig,
fields='__all__',
filters=TableConfigFilter,
@ -220,7 +219,7 @@ class TableConfigType(SharedObjectMixin, ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Tag,
exclude=['extras_taggeditem_items', ],
filters=TagFilter,
@ -232,7 +231,7 @@ class TagType(OwnerMixin, ObjectType):
object_types: list[ContentTypeType]
@strawberry_django.type(
@register_type(
models.Webhook,
exclude=['content_types',],
filters=WebhookFilter,
@ -242,7 +241,7 @@ class WebhookType(OwnerMixin, CustomFieldsMixin, TagsMixin, ObjectType):
pass
@strawberry_django.type(
@register_type(
models.EventRule,
exclude=['content_types',],
filters=EventRuleFilter,

View File

@ -2,6 +2,7 @@ from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from utilities.fields import NaturalOrderingField
from utilities.querysets import chunked_update
class Command(BaseCommand):
@ -93,7 +94,7 @@ class Command(BaseCommand):
self.stdout.flush()
# Update each unique field value in bulk
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
changed = chunked_update(model.objects.filter(name=value), **{field.name: naturalized_value})
if options['verbosity'] >= 2:
self.stdout.write(f" ({changed})")

View File

@ -8,7 +8,7 @@ import jsonschema
from django import forms
from django.conf import settings
from django.core.validators import RegexValidator, ValidationError
from django.db import models, transaction
from django.db import models
from django.db.models import F, Func, Value
from django.db.models.expressions import RawSQL
from django.urls import reverse
@ -19,7 +19,6 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
from core.models import ObjectType
from extras.choices import *
from extras.constants import CUSTOMFIELD_DATA_BATCH_SIZE
from extras.data import CHOICE_SETS
from extras.fields import ChoiceSetField
from netbox.context import query_cache
@ -44,9 +43,9 @@ from utilities.forms.fields import (
from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.jsonschema import validate_schema
from utilities.querysets import RestrictedQuerySet
from utilities.querysets import RestrictedQuerySet, chunked_update
from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import validate_regex
from utilities.validators import url_scheme_is_allowed, validate_regex
__all__ = (
'CustomField',
@ -329,32 +328,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
return self.choice_set.get_choice_color(value)
return None
@staticmethod
def _update_object_data(model, **update_kwargs):
"""
Apply an UPDATE to the custom_field_data of every instance of the given model in batches,
bounding the number of rows touched by each statement. A single unbounded UPDATE across
millions of rows can exceed the database statement timeout, because JSONB updates rewrite
each affected row in full. Batches are selected via keyset pagination on the primary key.
The batched updates are wrapped in a transaction so that the operation remains atomic, as
it was when performed by a single UPDATE. This guards against partially-applied data (e.g.
a renamed field landing on only some objects) should the loop be interrupted when not
already running inside a request's transaction. Batching avoids the statement timeout
regardless, as that limit applies per statement rather than per transaction.
"""
with transaction.atomic():
last_pk = 0
while True:
pks = list(
model.objects.filter(pk__gt=last_pk).order_by('pk')
.values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE]
)
if not pks:
break
model.objects.filter(pk__in=pks).update(**update_kwargs)
last_pk = pks[-1]
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -367,8 +340,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
value = Value(self.default, models.JSONField())
for ct in content_types:
if model := ct.model_class():
self._update_object_data(
model,
chunked_update(
model.objects.all(),
custom_field_data=Func(
F('custom_field_data'),
Value([self.name]),
@ -384,8 +357,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
"""
for ct in content_types:
if model := ct.model_class():
self._update_object_data(
model,
chunked_update(
model.objects.all(),
custom_field_data=F('custom_field_data') - self.name
)
@ -396,8 +369,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
"""
for ct in self.object_types.all():
if model := ct.model_class():
self._update_object_data(
model,
chunked_update(
model.objects.all(),
custom_field_data=Func(
F('custom_field_data') - old_name,
Value([new_name]),
@ -796,6 +769,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
elif self.type == CustomFieldTypeChoices.TYPE_URL:
if type(value) is not str:
raise ValidationError(_("Value must be a string."))
# Enforce ALLOWED_URL_SCHEMES to guard against dangerous schemes (e.g. javascript:). A
# schemeless value is permitted and treated as relative.
if not url_scheme_is_allowed(value):
raise ValidationError(
_("URLs must use a scheme permitted by ALLOWED_URL_SCHEMES.")
)
if self.validation_regex and not re.match(self.validation_regex, value):
raise ValidationError(_("Value must match regex '{regex}'").format(regex=self.validation_regex))

View File

@ -1,10 +1,9 @@
import datetime
import json
from decimal import Decimal
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import tag
from django.test import override_settings, tag
from django.urls import reverse
from rest_framework import status
@ -674,7 +673,7 @@ class CustomFieldTestCase(TestCase):
self.assertNotIn('field1', site.custom_field_data)
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
@patch('extras.models.customfields.CUSTOMFIELD_DATA_BATCH_SIZE', 2)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_batched_object_data_updates(self):
"""
Provisioning, renaming, and removing custom field data is applied in batches. Use a small
@ -1693,6 +1692,38 @@ class CustomFieldAPITestCase(APITestCase):
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_url_scheme_validation(self):
"""
Test that URL custom field values must use a scheme permitted by ALLOWED_URL_SCHEMES (fixes
#22640), and that a schemeless value is normalized to an absolute URL (assume_scheme='https'),
consistent with the UI.
"""
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
# A dangerous scheme (e.g. javascript:) must be rejected
data = {'custom_fields': {'url_field': 'javascript:alert(1)'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# A well-formed URL using a scheme outside ALLOWED_URL_SCHEMES must be rejected
data = {'custom_fields': {'url_field': 'gopher://example.com'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# An allowed scheme must be accepted
data = {'custom_fields': {'url_field': 'https://example.com'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
# A schemeless value must be accepted and normalized to https, matching the UI
data = {'custom_fields': {'url_field': 'example.com'}}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
site2.refresh_from_db()
self.assertEqual(site2.custom_field_data['url_field'], 'https://example.com')
def test_json_schema_validation(self):
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

View File

@ -17,6 +17,7 @@ from netbox.graphql.filters import (
NetBoxModelFilter,
OrganizationalModelFilter,
PrimaryModelFilter,
register_filter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface
@ -52,7 +53,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.ASN, lookups=True)
@register_filter(models.ASN, lookups=True)
class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
rir_id: ID | None = strawberry_django.filter_field()
@ -69,7 +70,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
) = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ASNRange, lookups=True)
@register_filter(models.ASNRange, lookups=True)
class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -83,7 +84,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
)
@strawberry_django.filter_type(models.Aggregate, lookups=True)
@register_filter(models.Aggregate, lookups=True)
class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: StrFilterLookup | None = strawberry_django.filter_field()
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@ -116,7 +117,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
return Q(**{f"{prefix}prefix__family": value.value})
@strawberry_django.filter_type(models.FHRPGroup, lookups=True)
@register_filter(models.FHRPGroup, lookups=True)
class FHRPGroupFilter(PrimaryModelFilter):
group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@ -134,7 +135,7 @@ class FHRPGroupFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
@register_filter(models.FHRPGroupAssignment, lookups=True)
class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -173,7 +174,7 @@ class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
return Q(**{f"{prefix}interface_id__in": interface_ids})
@strawberry_django.filter_type(models.IPAddress, lookups=True)
@register_filter(models.IPAddress, lookups=True)
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
address: StrFilterLookup | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@ -224,7 +225,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
return Q(**{f"{prefix}address__family": value.value})
@strawberry_django.filter_type(models.IPRange, lookups=True)
@register_filter(models.IPRange, lookups=True)
class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
start_address: StrFilterLookup | None = strawberry_django.filter_field()
end_address: StrFilterLookup | None = strawberry_django.filter_field()
@ -278,7 +279,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
return q
@strawberry_django.filter_type(models.Prefix, lookups=True)
@register_filter(models.Prefix, lookups=True)
class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
prefix: StrFilterLookup | None = strawberry_django.filter_field()
vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@ -315,19 +316,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
return Q(**{f"{prefix}prefix__family": value.value})
@strawberry_django.filter_type(models.RIR, lookups=True)
@register_filter(models.RIR, lookups=True)
class RIRFilter(OrganizationalModelFilter):
is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Role, lookups=True)
@register_filter(models.Role, lookups=True)
class RoleFilter(OrganizationalModelFilter):
weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.RouteTarget, lookups=True)
@register_filter(models.RouteTarget, lookups=True)
class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@ -344,7 +345,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.Service, lookups=True)
@register_filter(models.Service, lookups=True)
class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@ -356,12 +357,12 @@ class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
parent_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
@register_filter(models.ServiceTemplate, lookups=True)
class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLAN, lookups=True)
@register_filter(models.VLAN, lookups=True)
class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
site_id: ID | None = strawberry_django.filter_field()
@ -393,7 +394,7 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.VLANGroup, lookups=True)
@register_filter(models.VLANGroup, lookups=True)
class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@ -401,12 +402,12 @@ class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
total_vlan_ids: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
@register_filter(models.VLANTranslationPolicy, lookups=True)
class VLANTranslationPolicyFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
@register_filter(models.VLANTranslationRule, lookups=True)
class VLANTranslationRuleFilter(NetBoxModelFilter):
policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -421,7 +422,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilter):
)
@strawberry_django.filter_type(models.VRF, lookups=True)
@register_filter(models.VRF, lookups=True)
class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
rd: StrFilterLookup | None = strawberry_django.filter_field()

View File

@ -8,7 +8,13 @@ from dcim.graphql.types import SiteType
from extras.graphql.mixins import ContactsMixin
from ipam import models
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import (
BaseObjectType,
NetBoxObjectType,
OrganizationalObjectType,
PrimaryObjectType,
register_type,
)
from .filters import *
from .mixins import IPAddressesMixin
@ -68,7 +74,7 @@ class BaseIPAddressFamilyType:
return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
@strawberry_django.type(
@register_type(
models.ASN,
fields='__all__',
filters=ASNFilter,
@ -84,7 +90,7 @@ class ASNType(ContactsMixin, PrimaryObjectType):
providers: list[ProviderType]
@strawberry_django.type(
@register_type(
models.ASNRange,
fields='__all__',
filters=ASNRangeFilter,
@ -97,7 +103,7 @@ class ASNRangeType(OrganizationalObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Aggregate,
fields='__all__',
filters=AggregateFilter,
@ -109,7 +115,7 @@ class AggregateType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.FHRPGroup,
fields='__all__',
filters=FHRPGroupFilter,
@ -119,7 +125,7 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
fhrpgroupassignment_set: list[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.FHRPGroupAssignment,
exclude=['interface_type', 'interface_id'],
filters=FHRPGroupAssignmentFilter,
@ -137,7 +143,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
return self.interface
@strawberry_django.type(
@register_type(
models.IPAddress,
exclude=['assigned_object_type', 'assigned_object_id', 'address'],
filters=IPAddressFilter,
@ -163,7 +169,7 @@ class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
return self.assigned_object
@strawberry_django.type(
@register_type(
models.IPRange,
fields='__all__',
filters=IPRangeFilter,
@ -177,7 +183,7 @@ class IPRangeType(ContactsMixin, PrimaryObjectType):
role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Prefix,
exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
filters=PrefixFilter,
@ -201,7 +207,7 @@ class PrefixType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
return self.scope
@strawberry_django.type(
@register_type(
models.RIR,
fields='__all__',
filters=RIRFilter,
@ -214,7 +220,7 @@ class RIRType(OrganizationalObjectType):
aggregates: list[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Role,
fields='__all__',
filters=RoleFilter,
@ -227,7 +233,7 @@ class RoleType(OrganizationalObjectType):
vlans: list[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.RouteTarget,
fields='__all__',
filters=RouteTargetFilter,
@ -242,7 +248,7 @@ class RouteTargetType(PrimaryObjectType):
exporting_vrfs: list[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.Service,
exclude=('_ports_lowest', 'parent_object_type', 'parent_object_id'),
filters=ServiceFilter,
@ -262,7 +268,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
return self.parent
@strawberry_django.type(
@register_type(
models.ServiceTemplate,
exclude=('_ports_lowest',),
filters=ServiceTemplateFilter,
@ -272,7 +278,7 @@ class ServiceTemplateType(PrimaryObjectType):
ports: list[int]
@strawberry_django.type(
@register_type(
models.VLAN,
exclude=['qinq_svlan'],
filters=VLANFilter,
@ -296,7 +302,7 @@ class VLANType(PrimaryObjectType):
return self.qinq_svlan
@strawberry_django.type(
@register_type(
models.VLANGroup,
exclude=['scope_type', 'scope_id'],
filters=VLANGroupFilter,
@ -323,7 +329,7 @@ class VLANGroupType(OrganizationalObjectType):
return self.scope
@strawberry_django.type(
@register_type(
models.VLANTranslationPolicy,
fields='__all__',
filters=VLANTranslationPolicyFilter,
@ -333,7 +339,7 @@ class VLANTranslationPolicyType(PrimaryObjectType):
rules: list[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VLANTranslationRule,
fields='__all__',
filters=VLANTranslationRuleFilter,
@ -346,7 +352,7 @@ class VLANTranslationRuleType(NetBoxObjectType):
] = strawberry_django.field(select_related=["policy"])
@strawberry_django.type(
@register_type(
models.VRF,
fields='__all__',
filters=VRFFilter,

View File

@ -2,6 +2,7 @@ from django.core.management.base import BaseCommand
from ipam.models import VRF, Prefix
from ipam.utils import rebuild_prefixes
from utilities.querysets import chunked_update
class Command(BaseCommand):
@ -11,7 +12,7 @@ class Command(BaseCommand):
self.stdout.write(f'Rebuilding {Prefix.objects.count()} prefixes...')
# Reset existing counts
Prefix.objects.update(_depth=0, _children=0)
chunked_update(Prefix.objects.all(), _depth=0, _children=0)
# Rebuild the global table
global_count = Prefix.objects.filter(vrf__isnull=True).count()

View File

@ -16,6 +16,7 @@ class RebuildPrefixesTestCase(TestCase):
patch('ipam.management.commands.rebuild_prefixes.Prefix') as prefix_model,
patch('ipam.management.commands.rebuild_prefixes.VRF') as vrf_model,
patch('ipam.management.commands.rebuild_prefixes.rebuild_prefixes') as rebuild_prefixes,
patch('ipam.management.commands.rebuild_prefixes.chunked_update') as chunked_update,
):
prefix_model.objects.count.return_value = 0
prefix_model.objects.filter.return_value.count.return_value = 0
@ -23,7 +24,7 @@ class RebuildPrefixesTestCase(TestCase):
call_command('rebuild_prefixes', stdout=out)
rebuild_prefixes.assert_called_once_with(None)
prefix_model.objects.update.assert_called_once_with(_depth=0, _children=0)
chunked_update.assert_called_once_with(prefix_model.objects.all.return_value, _depth=0, _children=0)
self.assertIn('Rebuilding 0 prefixes', out.getvalue())
self.assertIn('Finished.', out.getvalue())
@ -68,6 +69,7 @@ class RebuildPrefixesTestCase(TestCase):
patch('ipam.management.commands.rebuild_prefixes.Prefix') as prefix_model,
patch('ipam.management.commands.rebuild_prefixes.VRF') as vrf_model,
patch('ipam.management.commands.rebuild_prefixes.rebuild_prefixes') as rebuild_prefixes,
patch('ipam.management.commands.rebuild_prefixes.chunked_update'),
):
prefix_model.objects.count.return_value = 3
prefix_model.objects.filter.side_effect = [

View File

@ -7,6 +7,7 @@ from strawberry_django import ComparisonFilterLookup, StrFilterLookup
from core.graphql.filter_mixins import ChangeLoggingMixin
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
from netbox.graphql.utils import register_model_graphql_type
if TYPE_CHECKING:
from .filters import *
@ -18,9 +19,20 @@ __all__ = (
'NetBoxModelFilter',
'OrganizationalModelFilter',
'PrimaryModelFilter',
'register_filter',
)
def register_filter(model, **kwargs):
"""
Drop-in replacement for `strawberry_django.filter_type()` for model-bound NetBox GraphQL filters. Before
delegating to `strawberry_django.filter_type()`, any plugin-registered filter mixins for the given model are
spliced into the decorated class's bases. With no extensions registered this is an exact pass-through, leaving
schema output unchanged. See `register_model_graphql_type` for the registry-timing contract.
"""
return register_model_graphql_type(model, strawberry_django.filter_type, 'graphql_filter_extensions', **kwargs)
@dataclass
class BaseModelFilter:
id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()

View File

@ -7,6 +7,7 @@ from strawberry.types import Info
from core.graphql.mixins import ChangelogMixin
from core.models import ObjectType as ObjectType_
from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMixin
from netbox.graphql.utils import register_model_graphql_type
from users.graphql.mixins import OwnerMixin
__all__ = (
@ -19,9 +20,20 @@ __all__ = (
'ObjectType',
'OrganizationalObjectType',
'PrimaryObjectType',
'register_type',
)
def register_type(model, **kwargs):
"""
Drop-in replacement for `strawberry_django.type()` for model-bound NetBox GraphQL output types. Before delegating
to `strawberry_django.type()`, any plugin-registered output-type mixins for the given model are spliced into the
decorated class's bases. With no extensions registered this is an exact pass-through, leaving schema output
unchanged. See `register_model_graphql_type` for the registry-timing contract.
"""
return register_model_graphql_type(model, strawberry_django.type, 'graphql_type_extensions', **kwargs)
#
# Base types
#
@ -159,7 +171,7 @@ class NetBoxObjectType(
# Miscellaneous types
#
@strawberry_django.type(
@register_type(
ContentType,
fields=['id', 'app_label', 'model'],
pagination=True
@ -168,7 +180,7 @@ class ContentTypeType:
pass
@strawberry_django.type(
@register_type(
ObjectType_,
fields=['id', 'app_label', 'model'],
pagination=True

View File

@ -0,0 +1,126 @@
import logging
from netbox.registry import registry
__all__ = (
'get_model_label',
'register_model_graphql_type',
'splice_extension_bases',
)
def get_model_label(model):
"""
Return the canonical `app_label.model_name` label used to key GraphQL extensions in the registry. Both the
registration side and the lookup side must derive labels through this helper so they always agree.
"""
return f'{model._meta.app_label}.{model._meta.model_name}'
def _own_names(klass):
"""
Return the set of field/attribute names a single class contributes directly: its annotations and its own
non-dunder attributes (such as resolver methods), excluding the `models` extension marker.
"""
names = set(getattr(klass, '__annotations__', {}))
names |= {name for name in vars(klass) if not name.startswith('__')}
names.discard('models')
return names
def _core_names(cls):
"""
Return every name `cls` resolves (its own body and everything it inherits). Extensions are spliced in *after*
these bases, so any name already present here is provided by the core type and an extension cannot override it.
"""
names = set()
for klass in cls.__mro__:
if klass is object:
continue
names |= _own_names(klass)
return names
def splice_extension_bases(cls, extensions):
"""
Return a class equivalent to `cls` but with the given plugin extension mixin classes spliced into its bases,
so that fields/filters they declare are picked up when the class is processed by Strawberry.
If `extensions` is empty, `cls` is returned unchanged (an exact pass-through). Otherwise a new class is built
with the same name and namespace as `cls` preserving its own annotations, fields, and methods with the
extension classes appended to its bases.
Precedence: extensions are appended *after* `cls`'s base classes in the MRO, so extensions are strictly
additive. Any name the core type already provides (its own fields or anything it inherits, including hooks such
as `get_queryset`) always wins; an extension declaring such a name is ignored. When two extensions declare the
same new name, the one whose plugin loaded first wins (it is registered earlier). Both cases are warned about.
"""
if not extensions:
return cls
# Fetch the logger lazily rather than at module import. Importing this module during settings/app loading
# (e.g. via the plugin registration helpers) happens before Django configures logging; creating the logger
# then would get it disabled by a `disable_existing_loggers` LOGGING config.
logger = logging.getLogger('netbox.graphql')
# Warn on field-name collisions so they can be diagnosed in deployments with many plugins.
core_names = _core_names(cls)
seen = {}
for extension in extensions:
for name in _own_names(extension):
if name in core_names:
logger.warning(
"GraphQL extension %s declares '%s', which core type %s already provides; the extension's "
"version is ignored (core takes precedence).",
extension, name, cls.__name__,
)
elif name in seen:
logger.warning(
"GraphQL extensions %s and %s both define '%s' on %s; %s takes precedence because its "
"plugin is loaded first.",
seen[name], extension, name, cls.__name__, seen[name],
)
else:
seen[name] = extension
namespace = dict(cls.__dict__)
# Drop the descriptors that cannot (and need not) be copied to the rebuilt class; they are recreated by the
# metaclass call below.
namespace.pop('__dict__', None)
namespace.pop('__weakref__', None)
bases = (*cls.__bases__, *extensions)
# Rebuild via the class's own metaclass rather than the built-in `type`, so a core type using a custom
# metaclass is preserved. Pre-decoration Strawberry/dataclass types use the plain `type` metaclass.
try:
return type(cls)(cls.__name__, bases, namespace)
except TypeError as exc:
raise TypeError(
f"Failed to splice GraphQL extension(s) {[e.__name__ for e in extensions]} into core type "
f"'{cls.__name__}': {exc}. A GraphQL extension should be a plain @strawberry.type mixin that only "
f"adds fields; inheriting from classes already in the core type's base list can produce an "
f"inconsistent MRO."
) from exc
def register_model_graphql_type(model, delegate, store_key, **kwargs):
"""
Shared implementation behind `register_type` and `register_filter`. Returns a decorator that splices any
plugin extensions for `model` (from `store_key`) into the decorated class, then delegates to `delegate`
(`strawberry_django.type` / `filter_type`).
The registry is read at decoration (import) time. This is safe because the schema is assembled lazily from the
URLconf, after every plugin's `ready()` has run; importing a core `graphql/types.py` during app init would read
the registry too early and silently drop later-registered extensions.
"""
label = get_model_label(model)
def wrapper(cls):
# Record that this type/filter has been assembled, so a plugin that registers an extension after this
# point (e.g. because its ready() imported a core graphql module early) can be warned it is too late.
registry['plugins']['graphql_extensions_assembled'].add((store_key, label))
extensions = registry['plugins'][store_key].get(label)
cls = splice_extension_bases(cls, extensions)
return delegate(model, **kwargs)(cls)
return wrapper

View File

@ -21,6 +21,11 @@ registry['plugins'].update({
'installed': [],
'graphql_schemas': [],
'jinja_filters': {},
'graphql_type_extensions': collections.defaultdict(list),
'graphql_filter_extensions': collections.defaultdict(list),
# (store_key, label) pairs whose core type/filter has already been assembled, used to detect extensions
# registered too late to be spliced in.
'graphql_extensions_assembled': set(),
'menus': [],
'menu_items': {},
'preferences': {},
@ -32,6 +37,8 @@ DEFAULT_RESOURCE_PATHS = {
'data_backends': 'data_backends.backends',
'graphql_schema': 'graphql.schema',
'jinja_filters': 'jinja_env.filters',
'graphql_type_extensions': 'graphql.type_extensions',
'graphql_filter_extensions': 'graphql.filter_extensions',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
@ -81,6 +88,8 @@ class PluginConfig(AppConfig):
data_backends = None
graphql_schema = None
jinja_filters = None
graphql_type_extensions = None
graphql_filter_extensions = None
menu = None
menu_items = None
serializer_resolver = None
@ -151,6 +160,13 @@ class PluginConfig(AppConfig):
if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
# Register GraphQL type & filter extensions (if defined). These must be registered before the GraphQL
# schema is assembled (during ROOT_URLCONF loading), which occurs after all apps' ready() methods run.
if graphql_type_extensions := self._load_resource('graphql_type_extensions'):
register_graphql_type_extensions(graphql_type_extensions)
if graphql_filter_extensions := self._load_resource('graphql_filter_extensions'):
register_graphql_filter_extensions(graphql_filter_extensions)
# Register user preferences (if defined)
if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)

View File

@ -1,8 +1,10 @@
import inspect
import logging
from django.apps import apps
from django.utils.translation import gettext_lazy as _
from netbox.graphql.utils import get_model_label
from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
@ -11,7 +13,9 @@ from .templates import PluginTemplateExtension
logger = logging.getLogger(__name__)
__all__ = (
'register_graphql_filter_extensions',
'register_graphql_schema',
'register_graphql_type_extensions',
'register_jinja_filters',
'register_menu',
'register_menu_items',
@ -102,6 +106,74 @@ def register_graphql_schema(graphql_schema):
registry['plugins']['graphql_schemas'].extend(graphql_schema)
def _register_graphql_extensions(class_list, store):
"""
Collect a list of GraphQL output-type or filter mixin classes into the given registry store, bucketed by the
model labels declared on each class's `models` attribute. Each declared label is validated against the app
registry and normalized to the canonical `app_label.model_name` form (via `get_model_label`) so that the
stored key always matches the label `register_type`/`register_filter` look up.
"""
for extension in class_list:
if not inspect.isclass(extension):
raise TypeError(
_("GraphQL extension {extension} was passed as an instance!").format(extension=extension)
)
models = getattr(extension, 'models', None)
if not models:
raise TypeError(
_("GraphQL extension {extension} must declare a non-empty 'models' attribute.").format(
extension=extension
)
)
# Must be @strawberry.type-decorated for its fields to be collected. Check the class's own __dict__ (not
# hasattr) so an undecorated subclass of a @strawberry.type base is still rejected. `__strawberry_definition__`
# is a Strawberry internal (verified against strawberry-graphql 0.321.0); revisit on dependency upgrades.
if '__strawberry_definition__' not in vars(extension):
raise TypeError(
_("GraphQL extension {extension} must be decorated with @strawberry.type.").format(
extension=extension
)
)
for label in models:
# Resolve the model to validate the label and derive its canonical key; a bad label would otherwise
# register into a bucket that is never looked up, silently dropping the extension.
try:
model = apps.get_model(label)
except (LookupError, ValueError):
raise TypeError(
_("GraphQL extension {extension} targets unknown model '{label}'.").format(
extension=extension, label=label
)
)
canonical_label = get_model_label(model)
# If the core type/filter was already assembled (a plugin imported a core graphql module during
# ready()), this extension is too late to be spliced in and will not appear in the schema. Fetch the
# logger lazily (not the module-level one) so it isn't disabled by a `disable_existing_loggers` config.
if (store, canonical_label) in registry['plugins']['graphql_extensions_assembled']:
logging.getLogger('netbox.graphql').warning(
"GraphQL extension %s for '%s' was registered after the core type was assembled and will be "
"ignored. Avoid importing core GraphQL modules from a plugin's ready().",
extension, canonical_label,
)
registry['plugins'][store][canonical_label].append(extension)
def register_graphql_type_extensions(class_list):
"""
Register a list of GraphQL output-type mixin classes. Each class must be decorated with @strawberry.type and
declare a `models` attribute listing the `app_label.model` labels of the core types it extends.
"""
_register_graphql_extensions(class_list, 'graphql_type_extensions')
def register_graphql_filter_extensions(class_list):
"""
Register a list of GraphQL filter mixin classes. Each class must be decorated with @strawberry.type and declare
a `models` attribute listing the `app_label.model` labels of the core filters it extends.
"""
_register_graphql_extensions(class_list, 'graphql_filter_extensions')
def register_user_preferences(plugin_name, preferences):
"""
Register a list of user preferences defined by a plugin.

View File

@ -8,7 +8,6 @@ from django.db import DatabaseError, ProgrammingError, transaction
from django.db.models import F, Q, Window, prefetch_related_objects
from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from netaddr.core import AddrFormatError
@ -21,7 +20,6 @@ from utilities.querysets import RestrictedPrefetch
from utilities.string import title
from . import FieldTypes, LookupTypes, get_indexer
from .deferred import OP_CACHE, OP_REMOVE, mark_for_deferred_indexing
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
MAX_RESULTS = 1000
@ -64,11 +62,11 @@ class SearchBackend:
"""
raise NotImplementedError
# caching_handler() and removal_handler() are the default, synchronous signal receivers connected
# to post_save/post_delete at module load. They are internal plumbing for signal dispatch, not a
# documented extension point: the public backend contract is cache()/remove()/clear(). A backend
# that needs to do something other than index inline (e.g. defer the work) overrides these in its
# subclass; see CachedValueSearchBackend.
# caching_handler() and removal_handler() are the default, synchronous signal receivers; they are
# connected to post_save/post_delete from netbox.search.signals (wired from CoreConfig.ready()).
# They are internal plumbing for signal dispatch, not a documented extension point: the public
# backend contract is cache()/remove()/clear(). A backend that needs to do something other than
# index inline (e.g. defer the work) overrides these in its subclass; see CachedValueSearchBackend.
def caching_handler(self, sender, instance, created, **kwargs):
"""
Receiver for the post_save signal, responsible for caching object creation/changes.
@ -127,10 +125,18 @@ class CachedValueSearchBackend(SearchBackend):
# the originating routing context is gone, so the alias must be captured here and replayed on the
# deferred write to keep cache entries in the originating schema (e.g. a branch schema under
# netbox-branching). Deferral is internal to this backend; the public contract is unchanged.
#
# mark_for_deferred_indexing() etc. are imported inside each method rather than at module level:
# this module's own top would import deferred.py *before* search_backend is defined further down
# this same file, and deferred.py (plus jobs.py) need that singleton at their own module level.
# A module-level import here would close that loop into a backends -> deferred -> backends
# cycle. See #22485.
def caching_handler(self, sender, instance, created, using=None, **kwargs):
"""
Receiver for the post_save signal, responsible for caching object creation/changes.
"""
from .deferred import OP_CACHE, mark_for_deferred_indexing
# Skip non-cacheable objects without scheduling any deferred work.
try:
indexer = get_indexer(instance)
@ -150,6 +156,8 @@ class CachedValueSearchBackend(SearchBackend):
"""
Receiver for the post_delete signal, responsible for caching object deletion.
"""
from .deferred import OP_REMOVE, mark_for_deferred_indexing
# Skip non-cacheable objects without scheduling any deferred work.
try:
indexer = get_indexer(instance)
@ -440,7 +448,3 @@ def get_backend():
search_backend = get_backend()
# Connect handlers to the appropriate model signals
post_save.connect(search_backend.caching_handler)
post_delete.connect(search_backend.removal_handler)

View File

@ -4,6 +4,8 @@ from django.db import DEFAULT_DB_ALIAS, connections, transaction
from redis.exceptions import RedisError
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.search.backends import search_backend
from netbox.search.jobs import SearchCacheJob
from utilities.rqworker import any_workers_for_queue
# This module is internal plumbing for the search signal handlers; nothing here
@ -157,14 +159,6 @@ def _flush(batch, using):
groups = remove_groups if op == OP_REMOVE else cache_groups
groups.setdefault(object_type_id, []).append(pk)
# Imported here, not at module load, to avoid an import cycle: backends.py
# imports this module at module level (for the signal handlers), and
# netbox.search.jobs imports the search_backend singleton from backends.py,
# which is bound at the bottom of that module. A proper fix is tracked in
# #22485.
from netbox.search.backends import search_backend
from netbox.search.jobs import SearchCacheJob
try:
# Both the worker-availability check and the job enqueue talk to Redis,
# and a worker can die between the two. Treat any Redis failure across the

View File

@ -0,0 +1,31 @@
"""
Connects the global search cache's post_save/post_delete receivers.
Wired explicitly from CoreConfig.ready() (core/apps.py) rather than as a side effect of this
module being imported, so connection happens deterministically at startup instead of depending
on which of this subsystem's several consumers (netbox.forms.search, netbox.views.misc,
extras.management.commands.reindex, netbox.search.jobs, core.jobs, dcim.signals) happens to
import netbox.search first.
"""
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .backends import search_backend
@receiver(post_save)
def caching_handler(sender, instance, created, **kwargs):
"""
Update the search cache when an object is created or modified. Delegates to whichever
backend is configured; see SearchBackend.caching_handler().
"""
search_backend.caching_handler(sender, instance, created=created, **kwargs)
@receiver(post_delete)
def removal_handler(sender, instance, **kwargs):
"""
Remove an object's cached representation when it is deleted. Delegates to whichever
backend is configured; see SearchBackend.removal_handler().
"""
search_backend.removal_handler(sender, instance, **kwargs)

View File

@ -89,6 +89,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
},
])
BASE_PATH = trailing_slash(getattr(configuration, 'BASE_PATH', ''))
BULK_UPDATE_CHUNK_SIZE = getattr(configuration, 'BULK_UPDATE_CHUNK_SIZE', 5000)
if BULK_UPDATE_CHUNK_SIZE is not None and (type(BULK_UPDATE_CHUNK_SIZE) is not int or BULK_UPDATE_CHUNK_SIZE < 1):
raise ImproperlyConfigured(
f"BULK_UPDATE_CHUNK_SIZE must be a positive integer or None (found {BULK_UPDATE_CHUNK_SIZE!r})"
)
CHANGELOG_SKIP_EMPTY_CHANGES = getattr(configuration, 'CHANGELOG_SKIP_EMPTY_CHANGES', True)
CENSUS_REPORTING_ENABLED = getattr(configuration, 'CENSUS_REPORTING_ENABLED', True)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)

View File

@ -23,6 +23,7 @@ from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
from utilities.request import get_safe_request_context
from utilities.templatetags.builtins.filters import render_markdown
from utilities.validators import url_scheme_is_allowed
from utilities.views import get_action_url
__all__ = (
@ -562,7 +563,12 @@ class CustomFieldColumn(tables.Column):
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
# Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES, to guard against
# dangerous schemes (e.g. javascript:) in values which bypassed validation. A schemeless
# (relative) value is considered safe.
if url_scheme_is_allowed(value):
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
return escape(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.customfield.get_choice_label(value)
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:

View File

@ -1,6 +1,7 @@
import strawberry
import strawberry_django
from django.db.models import Q
from . import models
@ -22,3 +23,35 @@ class DummyQuery:
schema = [
DummyQuery,
]
#
# Extensions to core GraphQL types & filters (see netbox.graphql.types.register_type /
# netbox.graphql.filters.register_filter). These exercise the plugin extension point.
#
@strawberry.type
class SiteTypeExtension:
models = ['dcim.site']
@strawberry_django.field
def dummy_plugin_field(self) -> str:
return 'dummy-plugin-value'
@strawberry.type
class SiteFilterExtension:
models = ['dcim.site']
@strawberry_django.filter_field()
def dummy_plugin_filter(self, value: str, prefix) -> Q:
return Q(**{f'{prefix}name': value})
type_extensions = [
SiteTypeExtension,
]
filter_extensions = [
SiteFilterExtension,
]

View File

@ -1,7 +1,9 @@
import json
import re
from unittest import skipIf
import strawberry
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test import override_settings
@ -133,6 +135,26 @@ class GraphQLAPITestCase(APITestCase):
)
Site.objects.bulk_create(sites)
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_plugin_extensions_execute(self):
"""
A plugin-provided filter extension and field extension execute end-to-end against a live query,
exercising the custom filter method's prefix plumbing and the type extension's resolver.
"""
self.add_permissions('dcim.view_site')
url = reverse('graphql')
query = '{ site_list(filters: {dummy_plugin_filter: "Site 1"}) { name dummy_plugin_field } }'
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
sites = data['data']['site_list']
self.assertEqual(len(sites), 1)
self.assertEqual(sites[0]['name'], 'Site 1')
self.assertEqual(sites[0]['dummy_plugin_field'], 'dummy-plugin-value')
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
"""
@ -662,3 +684,130 @@ class JSONStringLookupTestCase(TestCase):
'starts_with', 'i_starts_with', 'ends_with', 'i_ends_with',
'in_', 'isnull', 'regex', 'i_regex'):
self.assertIn(expected, field_names, f"{expected!r} must be present on JSONStringLookup")
class SpliceExtensionBasesTestCase(TestCase):
"""Verify splice_extension_bases() behavior: pass-through, splicing, and collision warnings."""
@staticmethod
def _make_core():
@strawberry.type
class CoreBase:
description: str # inherited (non-protected) field
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
return queryset
@strawberry.type
class CoreType(CoreBase):
name: str # defined directly in the core type's own body
return CoreType
def test_no_extensions_is_passthrough(self):
from netbox.graphql.utils import splice_extension_bases
CoreType = self._make_core()
self.assertIs(splice_extension_bases(CoreType, []), CoreType)
self.assertIs(splice_extension_bases(CoreType, None), CoreType)
def test_extension_spliced_into_bases(self):
from netbox.graphql.utils import splice_extension_bases
@strawberry.type
class Extension:
models = ['dcim.device']
extra: str
CoreType = self._make_core()
result = splice_extension_bases(CoreType, [Extension])
self.assertIsNot(result, CoreType)
self.assertEqual(result.__name__, CoreType.__name__)
self.assertIn(Extension, result.__mro__)
# The extension is appended *after* the core bases in the MRO (additive, core wins collisions)
self.assertGreater(result.__mro__.index(Extension), result.__mro__.index(CoreType.__bases__[0]))
def test_warns_when_extension_collides_with_core_own_field(self):
# A name the core type defines directly always wins; the extension's version is ignored (and warned).
from netbox.graphql.utils import splice_extension_bases
@strawberry.type
class Extension:
models = ['dcim.device']
name: str # collides with CoreType.name (own body)
CoreType = self._make_core()
with self.assertLogs('netbox.graphql', level='WARNING') as cm:
splice_extension_bases(CoreType, [Extension])
self.assertTrue(any("already provides" in msg and "core takes precedence" in msg for msg in cm.output))
def test_warns_when_extension_collides_with_inherited_field(self):
# A name the core type inherits also wins over the extension (extensions are strictly additive).
from netbox.graphql.utils import splice_extension_bases
@strawberry.type
class Extension:
models = ['dcim.device']
description: str # collides with CoreBase.description (inherited)
CoreType = self._make_core()
with self.assertLogs('netbox.graphql', level='WARNING') as cm:
splice_extension_bases(CoreType, [Extension])
self.assertTrue(any("already provides" in msg for msg in cm.output))
def test_core_hook_wins_over_extension(self):
# An extension declaring get_queryset is ignored; the core permission-enforcing hook is preserved by
# ordering (extensions are appended after the core bases).
from netbox.graphql.utils import splice_extension_bases
@strawberry.type
class Extension:
models = ['dcim.device']
@classmethod
def get_queryset(cls, queryset, info, **kwargs):
return 'EXTENSION_WON'
CoreType = self._make_core()
with self.assertLogs('netbox.graphql', level='WARNING') as cm:
result = splice_extension_bases(CoreType, [Extension])
self.assertTrue(any("already provides" in msg and "get_queryset" in msg for msg in cm.output))
# Core's get_queryset (identity) is retained, not the extension's override
self.assertEqual(result.get_queryset('CORE_QS', None), 'CORE_QS')
def test_mro_conflict_raises_clear_error(self):
from netbox.graphql.utils import splice_extension_bases
class A:
pass
class B:
pass
class Core(A, B):
name = 'core'
class Extension(B, A): # reversed base order -> inconsistent MRO when spliced
models = ['dcim.device']
with self.assertRaises(TypeError) as ctx:
splice_extension_bases(Core, [Extension])
self.assertIn('Failed to splice', str(ctx.exception))
def test_warns_on_collision_between_extensions(self):
from netbox.graphql.utils import splice_extension_bases
@strawberry.type
class ExtensionA:
models = ['dcim.device']
widgets: str
@strawberry.type
class ExtensionB:
models = ['dcim.device']
widgets: str
CoreType = self._make_core()
with self.assertLogs('netbox.graphql', level='WARNING') as cm:
splice_extension_bases(CoreType, [ExtensionA, ExtensionB])
self.assertTrue(any("both define" in msg and "loaded first" in msg for msg in cm.output))

View File

@ -1,3 +1,4 @@
import re
from unittest import skipIf
from django.conf import settings
@ -216,6 +217,26 @@ class PluginTestCase(TestCase):
self.assertIn(DummyQuery, registry['plugins']['graphql_schemas'])
self.assertTrue(issubclass(Query, DummyQuery))
def test_graphql_type_extensions(self):
"""
Validate that plugin GraphQL type & filter extensions are registered and spliced into the built schema.
"""
from netbox.graphql.schema import schema
from netbox.tests.dummy_plugin.graphql import SiteFilterExtension, SiteTypeExtension
# Extensions are registered against the targeted core model
self.assertIn(SiteTypeExtension, registry['plugins']['graphql_type_extensions']['dcim.site'])
self.assertIn(SiteFilterExtension, registry['plugins']['graphql_filter_extensions']['dcim.site'])
# The injected field and filter appear in the assembled schema
schema_str = schema.as_str()
site_type = re.search(r'\ntype SiteType \{.*?\n\}', schema_str, re.DOTALL)
self.assertIsNotNone(site_type, "SiteType not found in GraphQL schema")
self.assertIn('dummy_plugin_field', site_type.group(0))
site_filter = re.search(r'\ninput SiteFilter \{.*?\n\}', schema_str, re.DOTALL)
self.assertIsNotNone(site_filter, "SiteFilter not found in GraphQL schema")
self.assertIn('dummy_plugin_filter', site_filter.group(0))
@override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}})
def test_get_plugin_config(self):
"""
@ -359,3 +380,93 @@ class PluginNavigationTestCase(TestCase):
self.assertIsNot(item1.permissions, item2.permissions)
self.assertEqual(item1.permissions, ['explicit_permission'])
self.assertEqual(item2.permissions, ['different_permission'])
class RegisterGraphQLExtensionsTestCase(TestCase):
"""Validate registration-time checks for GraphQL type/filter extensions."""
def test_rejects_extension_without_models(self):
import strawberry
from netbox.plugins.registration import register_graphql_type_extensions
@strawberry.type
class NoModels:
pass
with self.assertRaises(TypeError):
register_graphql_type_extensions([NoModels])
def test_rejects_undecorated_extension(self):
# A plain class (no @strawberry.type) must be rejected...
from netbox.plugins.registration import register_graphql_type_extensions
class Undecorated:
models = ['dcim.device']
with self.assertRaises(TypeError):
register_graphql_type_extensions([Undecorated])
def test_rejects_undecorated_subclass_of_strawberry_type(self):
# ...as must a subclass that only inherits __strawberry_definition__ without its own decoration.
import strawberry
from netbox.plugins.registration import register_graphql_type_extensions
@strawberry.type
class Base:
pass
class Child(Base):
models = ['dcim.device']
with self.assertRaises(TypeError):
register_graphql_type_extensions([Child])
def test_rejects_unknown_model_label(self):
import strawberry
from netbox.plugins.registration import register_graphql_type_extensions
@strawberry.type
class BadTarget:
models = ['dcim.notamodel']
with self.assertRaises(TypeError):
register_graphql_type_extensions([BadTarget])
def test_filter_extension_requires_strawberry_type(self):
# The filter path enforces the same @strawberry.type requirement as the type path.
from netbox.plugins.registration import register_graphql_filter_extensions
class UndecoratedFilter:
models = ['dcim.device']
with self.assertRaises(TypeError):
register_graphql_filter_extensions([UndecoratedFilter])
def test_warns_when_registered_after_assembly(self):
# An extension registered after its core type was already assembled is warned and will be dropped.
import strawberry
from netbox.plugins.registration import register_graphql_type_extensions
@strawberry.type
class LateExt:
models = ['dcim.cable']
late_field: str
store, label = 'graphql_type_extensions', 'dcim.cable'
assembled = registry['plugins']['graphql_extensions_assembled']
was_present = (store, label) in assembled
assembled.add((store, label))
# Restore global registry state regardless of outcome so other tests are unaffected.
self.addCleanup(lambda: registry['plugins'][store].__setitem__(
label, [e for e in registry['plugins'][store][label] if e is not LateExt]
))
if not was_present:
self.addCleanup(assembled.discard, (store, label))
with self.assertLogs('netbox.graphql', level='WARNING') as cm:
register_graphql_type_extensions([LateExt])
self.assertTrue(any('after the core type was assembled' in msg for msg in cm.output))

View File

@ -741,7 +741,8 @@ class CustomBackendContractTestCase(TransactionTestCase):
backend = _MinimalSearchBackend()
# Connect the custom backend's (inherited, synchronous) handlers, exactly as
# backends.py connects the configured backend at import. Same call site, no type-check.
# netbox.search.signals connects the configured backend from CoreConfig.ready(). Same
# call site, no type-check.
post_save.connect(backend.caching_handler, sender=Site)
post_delete.connect(backend.removal_handler, sender=Site)
self.addCleanup(post_save.disconnect, backend.caching_handler, sender=Site)
@ -757,9 +758,10 @@ class CustomBackendContractTestCase(TransactionTestCase):
self.assertEqual(len(backend.removed), 1)
def test_default_backend_defers_via_same_call_path(self):
# The default backend (CachedValueSearchBackend) IS connected at import, and reaches the
# SAME caching_handler call path -- but its override defers instead of indexing inline.
# This contrasts with the custom backend above: identical dispatch, polymorphic behavior.
# The default backend (CachedValueSearchBackend) IS connected via netbox.search.signals, and
# reaches the SAME caching_handler call path -- but its override defers instead of indexing
# inline. This contrasts with the custom backend above: identical dispatch, polymorphic
# behavior.
with transaction.atomic():
Site.objects.create(name='Default Defers', slug='default-defers')
scheduled = scheduled_search_flushes()

View File

@ -4,6 +4,8 @@ from django.test import RequestFactory, TestCase
from dcim.models import Device, Site
from dcim.tables import DeviceTable
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from netbox.tables import NetBoxTable, columns
from utilities.testing import create_tags, create_test_device, create_test_user
@ -119,3 +121,29 @@ class TagColumnTestCase(TestCase):
'table': table
})
template.render(context)
class CustomFieldColumnTestCase(TestCase):
"""
A URL custom field value is rendered directly into an href, so its scheme must be validated
against ALLOWED_URL_SCHEMES to avoid rendering dangerous schemes (e.g. javascript:) as clickable
links (fixes #22640).
"""
def _render(self, value):
customfield = CustomField(name='url_field', type=CustomFieldTypeChoices.TYPE_URL)
return columns.CustomFieldColumn(customfield).render(value)
def test_url_allowed_scheme_rendered_as_link(self):
self.assertEqual(self._render('https://example.com'), '<a href="https://example.com">https://example.com</a>')
def test_url_disallowed_scheme_not_rendered_as_link(self):
rendered = self._render('javascript:alert(1)')
self.assertNotIn('href', rendered)
self.assertIn('javascript:alert(1)', rendered)
def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
# A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
# so the value has no scheme and is rendered as a link as-is.
rendered = self._render('javascript%3Aalert(1)')
self.assertEqual(rendered, '<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>')

View File

@ -11,6 +11,7 @@ from netbox.graphql.filters import (
NestedGroupModelFilter,
OrganizationalModelFilter,
PrimaryModelFilter,
register_filter,
)
from tenancy import models
@ -58,7 +59,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.Tenant, lookups=True)
@register_filter(models.Tenant, lookups=True)
class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -137,7 +138,7 @@ class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.TenantGroup, lookups=True)
@register_filter(models.TenantGroup, lookups=True)
class TenantGroupFilter(OrganizationalModelFilter):
parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -151,7 +152,7 @@ class TenantGroupFilter(OrganizationalModelFilter):
)
@strawberry_django.filter_type(models.Contact, lookups=True)
@register_filter(models.Contact, lookups=True)
class ContactFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
title: StrFilterLookup | None = strawberry_django.filter_field()
@ -167,19 +168,19 @@ class ContactFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.ContactRole, lookups=True)
@register_filter(models.ContactRole, lookups=True)
class ContactRoleFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.ContactGroup, lookups=True)
@register_filter(models.ContactGroup, lookups=True)
class ContactGroupFilter(NestedGroupModelFilter):
parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ContactAssignment, lookups=True)
@register_filter(models.ContactAssignment, lookups=True)
class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()

View File

@ -1,10 +1,15 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import BaseObjectType, NestedLtreeGroupObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import (
BaseObjectType,
NestedLtreeGroupObjectType,
OrganizationalObjectType,
PrimaryObjectType,
register_type,
)
from tenancy import models
from .filters import *
@ -52,7 +57,7 @@ __all__ = (
# Tenants
#
@strawberry_django.type(
@register_type(
models.Tenant,
fields='__all__',
filters=TenantFilter,
@ -86,7 +91,7 @@ class TenantType(ContactsMixin, PrimaryObjectType):
l2vpns: list[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.TenantGroup,
exclude=['path', 'sort_path'],
filters=TenantGroupFilter,
@ -103,7 +108,7 @@ class TenantGroupType(NestedLtreeGroupObjectType):
# Contacts
#
@strawberry_django.type(
@register_type(
models.Contact,
fields='__all__',
filters=ContactFilter,
@ -113,7 +118,7 @@ class ContactType(ContactAssignmentsMixin, PrimaryObjectType):
groups: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ContactRole,
fields='__all__',
filters=ContactRoleFilter,
@ -123,7 +128,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
pass
@strawberry_django.type(
@register_type(
models.ContactGroup,
exclude=['path', 'sort_path'],
filters=ContactGroupFilter,
@ -136,7 +141,7 @@ class ContactGroupType(NestedLtreeGroupObjectType):
children: list[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ContactAssignment,
fields='__all__',
filters=ContactAssignmentFilter,

View File

@ -4,7 +4,7 @@ import strawberry
import strawberry_django
from strawberry_django import DatetimeFilterLookup, FilterLookup, StrFilterLookup
from netbox.graphql.filters import BaseModelFilter
from netbox.graphql.filters import BaseModelFilter, register_filter
from users import models
__all__ = (
@ -15,13 +15,13 @@ __all__ = (
)
@strawberry_django.filter_type(models.Group, lookups=True)
@register_filter(models.Group, lookups=True)
class GroupFilter(BaseModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.User, lookups=True)
@register_filter(models.User, lookups=True)
class UserFilter(BaseModelFilter):
username: StrFilterLookup | None = strawberry_django.filter_field()
first_name: StrFilterLookup | None = strawberry_django.filter_field()
@ -34,7 +34,7 @@ class UserFilter(BaseModelFilter):
groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Owner, lookups=True)
@register_filter(models.Owner, lookups=True)
class OwnerFilter(BaseModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()
@ -47,7 +47,7 @@ class OwnerFilter(BaseModelFilter):
users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.OwnerGroup, lookups=True)
@register_filter(models.OwnerGroup, lookups=True)
class OwnerGroupFilter(BaseModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
description: StrFilterLookup | None = strawberry_django.filter_field()

View File

@ -1,7 +1,6 @@
import strawberry_django
from netbox.graphql.types import BaseObjectType
from netbox.graphql.types import BaseObjectType, register_type
from users.models import Group, Owner, OwnerGroup, User
from .filters import *
@ -14,7 +13,7 @@ __all__ = (
)
@strawberry_django.type(
@register_type(
Group,
fields=['id', 'name'],
filters=GroupFilter,
@ -24,7 +23,7 @@ class GroupType(BaseObjectType):
pass
@strawberry_django.type(
@register_type(
User,
fields=[
'id', 'username', 'first_name', 'last_name', 'email', 'is_active', 'date_joined', 'groups',
@ -36,7 +35,7 @@ class UserType(BaseObjectType):
groups: list[GroupType]
@strawberry_django.type(
@register_type(
OwnerGroup,
fields=['id', 'name', 'description'],
filters=OwnerGroupFilter,
@ -46,7 +45,7 @@ class OwnerGroupType(BaseObjectType):
pass
@strawberry_django.type(
@register_type(
Owner,
fields=['id', 'group', 'name', 'description', 'user_groups', 'users'],
filters=OwnerFilter,

View File

@ -5,6 +5,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
from netbox.registry import registry
from .fields import CounterCacheField
from .querysets import chunked_update
def get_counters_for_model(model):
@ -37,7 +38,7 @@ def update_counts(model, field_name, related_query):
subquery = Subquery(
model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
)
return model.objects.update(**{
return chunked_update(model.objects.all(), **{
field_name: subquery
})

View File

@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_
__all__ = (
'ExpandableIPNetworkField',
'ExpandableNameField',
'ExpandableNumericField',
)
@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField):
return [value]
class ExpandableNumericField(ExpandableNameField):
"""
An ExpandableNameField intended for numeric values, yielding integer-compatible strings suitable for bulk creation.
Example: '[1-3]' => ['1', '2', '3']
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Replace the inherited alphanumeric default with numeric-specific guidance (unless one was supplied)
if not kwargs.get('help_text'):
self.help_text = _("Numeric ranges are supported for bulk creation (example: <code>[1-24]</code>).")
class ExpandableIPNetworkField(forms.CharField):
"""
A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.

View File

@ -47,10 +47,12 @@ class InlineFields:
Parameters:
fields: An iterable of form field names
label: The label text to render for the row (optional)
help_text: Explanatory text rendered beneath the entire set of fields (optional)
"""
def __init__(self, *fields, label=None):
def __init__(self, *fields, label=None, help_text=None):
self.fields = fields
self.label = label
self.help_text = help_text
class TabbedGroups:

View File

@ -1,4 +1,6 @@
from django.db.models import Prefetch, QuerySet
from django.conf import settings
from django.db import router, transaction
from django.db.models import Max, Prefetch, QuerySet
from users.constants import CONSTRAINT_TOKEN_USER
from utilities.permissions import get_permission_for_model, permission_is_exempt, qs_filter_from_constraints
@ -6,9 +8,70 @@ from utilities.permissions import get_permission_for_model, permission_is_exempt
__all__ = (
'RestrictedPrefetch',
'RestrictedQuerySet',
'chunked_update',
)
def chunked_update(queryset, chunk_size=None, **kwargs):
"""
Perform a bulk UPDATE on the given queryset, optionally splitting it into batches of at most
`chunk_size` rows. Bounding the number of rows touched by each statement avoids exceeding the
database's statement timeout when updating very large tables. Batches are selected via keyset
pagination on the primary key and wrapped in a transaction so that the operation remains atomic,
as it would be when performed by a single UPDATE. Returns the total number of rows updated
(matching the return value of QuerySet.update()).
If `chunk_size` is None, it falls back to the BULK_UPDATE_CHUNK_SIZE configuration parameter
(5000 by default). If that is also None, a single unbounded UPDATE is issued, identical to
calling queryset.update(**kwargs) directly.
:param queryset: The QuerySet identifying the rows to update
:param chunk_size: The maximum number of rows to update per statement (defaults to
settings.BULK_UPDATE_CHUNK_SIZE)
"""
if chunk_size is None:
chunk_size = settings.BULK_UPDATE_CHUNK_SIZE
if chunk_size is not None and (type(chunk_size) is not int or chunk_size < 1):
raise ValueError(f"chunk_size must be a positive integer or None (found {chunk_size!r})")
if chunk_size is None:
return queryset.update(**kwargs)
model = queryset.model
# Pin the entire operation to a single write database so that the PK lookups, the UPDATE
# statements, and the enclosing transaction all use the same connection. This preserves an
# explicit .using() on the queryset and otherwise honors the router's write destination.
using = queryset._db or router.db_for_write(model)
count = 0
last_pk = 0
# Upper bound on the PKs to process. Established lazily (see below) only once a second batch is
# known to be needed, so the common single-batch case incurs no extra aggregate query.
max_pk = None
with transaction.atomic(using=using):
while True:
batch = queryset.using(using).filter(pk__gt=last_pk).order_by('pk')
if max_pk is not None:
batch = batch.filter(pk__lte=max_pk)
pks = list(batch.values_list('pk', flat=True)[:chunk_size])
if not pks:
break
# Re-filter the original queryset by pk__in (rather than the model's default manager) so
# that its own filters are preserved and rows no longer matching them are left untouched.
count += queryset.using(using).filter(pk__in=pks).update(**kwargs)
last_pk = pks[-1]
# A batch shorter than chunk_size means the rows are exhausted; stop without issuing a
# trailing (empty) lookup. This keeps a single-batch update to one SELECT and one UPDATE.
if len(pks) < chunk_size:
break
# A full batch means more rows may remain. Capture the current maximum PK as an upper
# bound (once) so that rows inserted while the operation runs cannot keep extending it.
if max_pk is None:
max_pk = queryset.using(using).aggregate(_max=Max('pk'))['_max']
return count
class RestrictedPrefetch(Prefetch):
"""
Extend Django's Prefetch to accept a user and action to be passed to the

View File

@ -15,7 +15,11 @@
{% elif customfield.type == 'datetime' and value %}
{{ value|isodatetime }}
{% elif customfield.type == 'url' and value %}
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% if url_allowed %}
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
{% else %}
{{ value|truncatechars:70 }}
{% endif %}
{% elif customfield.type == 'json' and value is not None %}
<pre>{{ value|json }}</pre>
{% elif customfield.type == 'select' and value %}

View File

@ -6,28 +6,28 @@
<h2 class="col-9 offset-3">{{ heading }}</h2>
</div>
{% endif %}
{% for layout, title, items in rows %}
{% for row in rows %}
{% if layout == 'field' %}
{% if row.layout == 'field' %}
{# Single form field #}
{% render_field items.0 %}
{% render_field row.items.0 %}
{% elif layout == 'attribute' %}
{% elif row.layout == 'attribute' %}
{# A static attribute of the form's instance #}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end required">{{ title }}</label>
<label class="col-sm-3 col-form-label text-lg-end required">{{ row.title }}</label>
<div class="col">
<div class="form-control-plaintext">
{{ items.0|linkify }}
{{ row.items.0|linkify }}
</div>
</div>
</div>
{% elif layout == 'inline' %}
{% elif row.layout == 'inline' %}
{# Multiple form fields on the same line #}
<div class="row mb-3"{% if title %} role="group" aria-label="{{ title }}"{% endif %}>
<label class="col col-3 col-form-label text-lg-end{% if items|any_required %} required{% endif %}">{{ title|default:'' }}</label>
{% for field in items %}
<div class="row{% if not row.help_text %} mb-3{% endif %}"{% if row.title %} role="group" aria-label="{{ row.title }}"{% endif %}>
<label class="col col-3 col-form-label text-lg-end{% if row.items|any_required %} required{% endif %}">{{ row.title|default:'' }}</label>
{% for field in row.items %}
<div class="col mb-1">
{% render_field_with_aria field has_helptext=True %}
<div class="form-text" id="{{ field.auto_id }}_helptext">{% trans field.label %}</div>
@ -39,13 +39,21 @@
</div>
{% endfor %}
</div>
{% if row.help_text %}
{# Shared help text rendered beneath the entire set of inline fields #}
<div class="row mb-3">
<div class="col offset-3">
<span class="form-text">{{ row.help_text|safe }}</span>
</div>
</div>
{% endif %}
{% elif layout == 'tabs' %}
{% elif row.layout == 'tabs' %}
{# Tabbed groups of fields #}
<div class="row">
<div class="col offset-3">
<ul class="nav nav-pills mb-1" role="tablist">
{% for tab in items %}
{% for tab in row.items %}
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="{{ tab.id }}_tab" data-bs-toggle="tab" aria-controls="{{ tab.id }}" aria-selected="{% if tab.active %}true{% else %}false{% endif %}" data-bs-target="#{{ tab.id }}" class="nav-link {% if tab.active %}active{% endif %}">
{% trans tab.title %}
@ -56,7 +64,7 @@
</div>
</div>
<div class="tab-content p-0 border-0">
{% for tab in items %}
{% for tab in row.items %}
<div class="tab-pane {% if tab.active %}active{% endif %}" id="{{ tab.id }}" role="tabpanel" aria-labelledby="{{ tab.id }}_tab">
{% for field in tab.fields %}
{% render_field field %}

View File

@ -7,6 +7,7 @@ from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices
from utilities.querydict import dict_to_querydict
from utilities.validators import url_scheme_is_allowed
__all__ = (
'badge',
@ -48,6 +49,8 @@ def customfield_value(customfield, value):
"""
color = None
value_has_colors = False
# Determines whether a URL value may be rendered as a clickable link
url_allowed = False
if value:
if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
@ -58,11 +61,17 @@ def customfield_value(customfield, value):
value_has_colors = any(choice_color for _, choice_color in value)
if not value_has_colors:
value = [choice_label for choice_label, _ in value]
elif customfield.type == CustomFieldTypeChoices.TYPE_URL:
# Only render as a link if the scheme is permitted by ALLOWED_URL_SCHEMES. This guards against
# dangerous schemes (e.g. javascript:) in values stored before validation was enforced or via
# paths which bypass model validation. A schemeless (relative) value is considered safe.
url_allowed = url_scheme_is_allowed(value)
return {
'customfield': customfield,
'value': value,
'color': color,
'value_has_colors': value_has_colors,
'url_allowed': url_allowed,
}

View File

@ -1,4 +1,6 @@
import warnings
from collections.abc import Sequence
from typing import Any, NamedTuple
from django import forms, template
from django.conf import settings
@ -20,6 +22,17 @@ __all__ = (
register = template.Library()
class FieldsetRow(NamedTuple):
"""
A single row within a rendered fieldset. `layout` determines how the row's items are
rendered by the template (e.g. 'field', 'inline', 'tabs', 'attribute').
"""
layout: str
items: Sequence
title: Any = None
help_text: Any = None
#
# Filters
#
@ -131,7 +144,7 @@ def render_fieldset(form, fieldset):
form[name] for name in item.fields if name in form.fields
]
rows.append(
('inline', item.label, fields)
FieldsetRow('inline', fields, title=item.label, help_text=item.help_text)
)
# Tabbed groups of fields
@ -148,28 +161,28 @@ def render_fieldset(form, fieldset):
if not any(tab['active'] for tab in tabs):
tabs[0]['active'] = True
rows.append(
('tabs', None, tabs)
FieldsetRow('tabs', tabs)
)
elif type(item) is M2MAddRemoveFields:
if item.name in form.fields:
# Simple mode: render a single multi-select field
rows.append(
('field', None, [form[item.name]])
FieldsetRow('field', [form[item.name]])
)
else:
# Add/remove mode: render separate add and remove fields
for field_name in (f'add_{item.name}', f'remove_{item.name}'):
if field_name in form.fields:
rows.append(
('field', None, [form[field_name]])
FieldsetRow('field', [form[field_name]])
)
elif type(item) is ObjectAttribute:
value = getattr(form.instance, item.name)
label = value._meta.verbose_name if hasattr(value, '_meta') else item.name
rows.append(
('attribute', label.title(), [value])
FieldsetRow('attribute', [value], title=label.title())
)
# A single form field
@ -179,7 +192,7 @@ def render_fieldset(form, fieldset):
if field.name in getattr(form, 'nullable_fields', []):
field._nullable = True
rows.append(
('field', None, [field])
FieldsetRow('field', [field])
)
return {

View File

@ -0,0 +1,116 @@
from django.db import connection
from django.db.models import Count, F, IntegerField, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.test import TestCase, override_settings
from django.test.utils import CaptureQueriesContext
from extras.models import Tag
from utilities.querysets import chunked_update
class ChunkedUpdateTestCase(TestCase):
"""
Tests for the chunked_update() helper, which performs a bulk UPDATE optionally split into
batches bounded by the BULK_UPDATE_CHUNK_SIZE configuration parameter.
"""
@classmethod
def setUpTestData(cls):
Tag.objects.bulk_create([
Tag(name=f'Tag {i}', slug=f'tag-{i}', weight=i)
for i in range(1, 6) # Five tags, weights 1..5
])
@staticmethod
def _count_updates(queries):
return len([q for q in queries if q['sql'].strip().upper().startswith('UPDATE')])
@override_settings(BULK_UPDATE_CHUNK_SIZE=None)
def test_update_without_chunk_size(self):
"""
With BULK_UPDATE_CHUNK_SIZE set to None, a single unbounded UPDATE is issued.
"""
with CaptureQueriesContext(connection) as queries:
count = chunked_update(Tag.objects.all(), weight=100)
self.assertEqual(count, 5)
self.assertEqual(self._count_updates(queries.captured_queries), 1)
self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_update_with_chunk_size(self):
"""
With BULK_UPDATE_CHUNK_SIZE set, the update is split into batches; every row is updated
exactly once and the total count is returned.
"""
with CaptureQueriesContext(connection) as queries:
count = chunked_update(Tag.objects.all(), weight=100)
self.assertEqual(count, 5)
# Five rows in batches of two → three UPDATE statements
self.assertEqual(self._count_updates(queries.captured_queries), 3)
self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
def test_explicit_chunk_size_argument(self):
"""
An explicit chunk_size argument takes precedence over the configuration parameter.
"""
with CaptureQueriesContext(connection) as queries:
count = chunked_update(Tag.objects.all(), chunk_size=2, weight=100)
self.assertEqual(count, 5)
self.assertEqual(self._count_updates(queries.captured_queries), 3)
self.assertEqual(Tag.objects.filter(weight=100).count(), 5)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_f_expression_applied_once_per_row(self):
"""
An F() expression referencing the row's own column is applied exactly once per row, even
when the update is chunked (chunks are disjoint by primary key).
"""
original = {tag.pk: tag.weight for tag in Tag.objects.all()}
count = chunked_update(Tag.objects.all(), weight=F('weight') + 1)
self.assertEqual(count, 5)
for tag in Tag.objects.all():
self.assertEqual(tag.weight, original[tag.pk] + 1)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_correlated_subquery(self):
"""
A correlated subquery (OuterRef) resolves against each chunk's queryset, mirroring the
counter-rebuild pattern in utilities.counters.update_counts().
"""
# Set each tag's weight to the number of tags sharing its slug (always 1), proving the
# OuterRef binds correctly per-row across chunks.
subquery = Subquery(
Tag.objects.filter(slug=OuterRef('slug')).values('slug')
.annotate(c=Count('pk')).values('c'),
output_field=IntegerField()
)
count = chunked_update(Tag.objects.all(), chunk_size=2, weight=Coalesce(subquery, 0))
self.assertEqual(count, 5)
self.assertEqual(Tag.objects.filter(weight=1).count(), 5)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_filtered_queryset(self):
"""
Only rows matching the queryset's filter are updated when chunking.
"""
target_pks = list(Tag.objects.filter(weight__lte=3).values_list('pk', flat=True))
count = chunked_update(Tag.objects.filter(weight__lte=3), weight=0)
self.assertEqual(count, len(target_pks))
self.assertEqual(Tag.objects.filter(weight=0).count(), len(target_pks))
# Rows outside the filter are untouched (weights 4 and 5 remain)
self.assertEqual(Tag.objects.filter(weight__gt=3).count(), 2)
@override_settings(BULK_UPDATE_CHUNK_SIZE=2)
def test_empty_queryset(self):
"""
Updating an empty queryset is a no-op that returns zero.
"""
count = chunked_update(Tag.objects.filter(name='nonexistent'), weight=0)
self.assertEqual(count, 0)

View File

@ -39,6 +39,15 @@ class CustomFieldValueTagTestCase(TestCase):
)
cls.multiselect_field.object_types.set([object_type])
cls.url_field = CustomField.objects.create(
name='url_field',
type=CustomFieldTypeChoices.TYPE_URL,
)
cls.url_field.object_types.set([object_type])
def _render(self, customfield, value):
return render_to_string('builtins/customfield_value.html', customfield_value(customfield, value))
def test_select_choice_context_includes_color(self):
context = customfield_value(self.select_field, 'a')
@ -63,6 +72,23 @@ class CustomFieldValueTagTestCase(TestCase):
self.assertFalse(context['value_has_colors'])
self.assertEqual(context['value'], ['Option B'])
def test_url_allowed_scheme_rendered_as_link(self):
html = self._render(self.url_field, 'https://example.com')
self.assertInHTML('<a href="https://example.com">https://example.com</a>', html)
def test_url_disallowed_scheme_not_rendered_as_link(self):
# A dangerous scheme (e.g. one stored before validation was enforced) must not become a
# clickable href (fixes #22640).
html = self._render(self.url_field, 'javascript:alert(1)')
self.assertNotIn('href', html)
self.assertIn('javascript:alert(1)', html)
def test_url_percent_encoded_scheme_rendered_as_relative_link(self):
# A percent-encoded scheme is inert: a browser will not decode "%3A" to execute javascript:,
# so the value has no scheme and is rendered as a link as-is.
html = self._render(self.url_field, 'javascript%3Aalert(1)')
self.assertInHTML('<a href="javascript%3Aalert(1)">javascript%3Aalert(1)</a>', html)
class StaticWithParamsTestCase(TestCase):
"""
@ -346,3 +372,20 @@ class RenderFieldsetInlineRequiredTestCase(TestCase):
)
html = self._render(fieldset)
self.assertNotIn('col-form-label text-lg-end required', html)
def test_inline_help_text_rendered(self):
fieldset = FieldSet(
InlineFields('optional_field', 'another_optional', label='Combined', help_text='Shared guidance'),
)
html = self._render(fieldset)
# The shared help text is rendered in its own row (col offset-3) beneath the fields
self.assertIn('Shared guidance', html)
self.assertIn('col offset-3', html)
def test_inline_help_text_omitted_when_not_provided(self):
fieldset = FieldSet(
InlineFields('optional_field', 'another_optional', label='Combined'),
)
html = self._render(fieldset)
# With no help text, the shared help-text row (col offset-3) must not be rendered
self.assertNotIn('col offset-3', html)

View File

@ -1,5 +1,6 @@
import decimal
import re
from urllib.parse import urlparse
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
@ -12,6 +13,7 @@ __all__ = (
'EnhancedURLValidator',
'ExclusionValidator',
'MultipleOfValidator',
'url_scheme_is_allowed',
'validate_regex',
)
@ -72,6 +74,23 @@ class MultipleOfValidator(BaseValidator):
)
def url_scheme_is_allowed(value):
"""
Return True if the URL's scheme is permitted by ALLOWED_URL_SCHEMES. A schemeless (relative) value
is considered permitted.
The scheme is compared in lower case. A percent-encoded scheme (e.g. "javascript%3A…") yields no
scheme, matching browser behavior: a browser does not decode the scheme portion of an href, so such
a value is inert and is treated as relative. A malformed URL which cannot be parsed (e.g.
"http://[::1/foo") likewise yields no scheme.
"""
try:
scheme = urlparse(value).scheme.lower()
except ValueError:
scheme = ''
return not scheme or scheme in get_config().ALLOWED_URL_SCHEMES
def validate_regex(value):
"""
Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex

View File

@ -8,7 +8,7 @@ from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLo
from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
from extras.graphql.filter_mixins import ConfigContextFilterMixin
from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin
from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter
from netbox.graphql.filters import NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter, register_filter
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization import models
from virtualization.graphql.filter_mixins import VMComponentFilterMixin
@ -38,7 +38,7 @@ __all__ = (
)
@strawberry_django.filter_type(models.Cluster, lookups=True)
@register_filter(models.Cluster, lookups=True)
class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
@ -57,19 +57,19 @@ class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, P
)
@strawberry_django.filter_type(models.ClusterGroup, lookups=True)
@register_filter(models.ClusterGroup, lookups=True)
class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilter):
vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.ClusterType, lookups=True)
@register_filter(models.ClusterType, lookups=True)
class ClusterTypeFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.VirtualMachineType, lookups=True)
@register_filter(models.VirtualMachineType, lookups=True)
class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter):
default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -87,7 +87,7 @@ class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter):
virtual_machine_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.VirtualMachine, lookups=True)
@register_filter(models.VirtualMachine, lookups=True)
class VirtualMachineFilter(
ContactFilterMixin,
ImageAttachmentFilterMixin,
@ -157,7 +157,7 @@ class VirtualMachineFilter(
)
@strawberry_django.filter_type(models.VMInterface, lookups=True)
@register_filter(models.VMInterface, lookups=True)
class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBoxModelFilter):
ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field()
@ -182,7 +182,7 @@ class VMInterfaceFilter(InterfaceBaseFilterMixin, VMComponentFilterMixin, NetBox
)
@strawberry_django.filter_type(models.VirtualDisk, lookups=True)
@register_filter(models.VirtualDisk, lookups=True)
class VirtualDiskFilter(VMComponentFilterMixin, NetBoxModelFilter):
size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()

View File

@ -6,7 +6,7 @@ import strawberry_django
from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType, register_type
from users.graphql.mixins import OwnerMixin
from virtualization import models
@ -46,7 +46,7 @@ class ComponentType(OwnerMixin, NetBoxObjectType):
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]
@strawberry_django.type(
@register_type(
models.Cluster,
exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
filters=ClusterFilter,
@ -70,7 +70,7 @@ class ClusterType(ContactsMixin, VLANGroupsMixin, PrimaryObjectType):
return self.scope
@strawberry_django.type(
@register_type(
models.ClusterGroup,
fields='__all__',
filters=ClusterGroupFilter,
@ -81,7 +81,7 @@ class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType)
clusters: list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type(
@register_type(
models.ClusterType,
fields='__all__',
filters=ClusterTypeFilter,
@ -92,7 +92,7 @@ class ClusterTypeType(OrganizationalObjectType):
clusters: list[ClusterType]
@strawberry_django.type(
@register_type(
models.VirtualMachineType,
fields='__all__',
filters=VirtualMachineTypeFilter,
@ -105,7 +105,7 @@ class VirtualMachineTypeType(PrimaryObjectType):
instances: list[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VirtualMachine,
fields='__all__',
filters=VirtualMachineFilter,
@ -131,7 +131,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType):
virtualdisks: list[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VMInterface,
fields='__all__',
filters=VMInterfaceFilter,
@ -154,7 +154,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
mac_addresses: list[Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
@register_type(
models.VirtualDisk,
fields='__all__',
filters=VirtualDiskFilter,

View File

@ -2,6 +2,8 @@ from django.db.models import Sum
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from utilities.querysets import chunked_update
from .models import Cluster, VirtualDisk, VirtualMachine
@ -22,4 +24,4 @@ def update_virtualmachine_site(instance, **kwargs):
Update the assigned site for all VMs to match that of the Cluster (if any).
"""
if instance._site:
VirtualMachine.objects.filter(cluster=instance).update(site=instance._site)
chunked_update(VirtualMachine.objects.filter(cluster=instance), site=instance._site)

View File

@ -11,6 +11,7 @@ from netbox.graphql.filters import (
NetBoxModelFilter,
OrganizationalModelFilter,
PrimaryModelFilter,
register_filter,
)
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from vpn import models
@ -36,12 +37,12 @@ __all__ = (
)
@strawberry_django.filter_type(models.TunnelGroup, lookups=True)
@register_filter(models.TunnelGroup, lookups=True)
class TunnelGroupFilter(OrganizationalModelFilter):
pass
@strawberry_django.filter_type(models.TunnelTermination, lookups=True)
@register_filter(models.TunnelTermination, lookups=True)
class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
tunnel_id: ID | None = strawberry_django.filter_field()
@ -61,7 +62,7 @@ class TunnelTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLo
outside_ip_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.Tunnel, lookups=True)
@register_filter(models.Tunnel, lookups=True)
class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
status: BaseFilterLookup[Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@ -87,7 +88,7 @@ class TunnelFilter(TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.IKEProposal, lookups=True)
@register_filter(models.IKEProposal, lookups=True)
class IKEProposalFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
authentication_method: (
@ -116,7 +117,7 @@ class IKEProposalFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.IKEPolicy, lookups=True)
@register_filter(models.IKEPolicy, lookups=True)
class IKEPolicyFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
version: BaseFilterLookup[Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@ -131,7 +132,7 @@ class IKEPolicyFilter(PrimaryModelFilter):
preshared_key: StrFilterLookup | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.IPSecProposal, lookups=True)
@register_filter(models.IPSecProposal, lookups=True)
class IPSecProposalFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
encryption_algorithm: (
@ -157,7 +158,7 @@ class IPSecProposalFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.IPSecPolicy, lookups=True)
@register_filter(models.IPSecPolicy, lookups=True)
class IPSecPolicyFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
@ -168,7 +169,7 @@ class IPSecPolicyFilter(PrimaryModelFilter):
)
@strawberry_django.filter_type(models.IPSecProfile, lookups=True)
@register_filter(models.IPSecProfile, lookups=True)
class IPSecProfileFilter(PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
mode: BaseFilterLookup[Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')]] | None = (
@ -184,7 +185,7 @@ class IPSecProfileFilter(PrimaryModelFilter):
ipsec_policy_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.L2VPN, lookups=True)
@register_filter(models.L2VPN, lookups=True)
class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
name: StrFilterLookup | None = strawberry_django.filter_field()
slug: StrFilterLookup | None = strawberry_django.filter_field()
@ -208,7 +209,7 @@ class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
)
@strawberry_django.filter_type(models.L2VPNTermination, lookups=True)
@register_filter(models.L2VPNTermination, lookups=True)
class L2VPNTerminationFilter(NetBoxModelFilter):
l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field()
l2vpn_id: ID | None = strawberry_django.filter_field()

View File

@ -4,7 +4,13 @@ import strawberry
import strawberry_django
from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
from netbox.graphql.types import (
NetBoxObjectType,
ObjectType,
OrganizationalObjectType,
PrimaryObjectType,
register_type,
)
from vpn import models
from .filters import *
@ -30,7 +36,7 @@ __all__ = (
)
@strawberry_django.type(
@register_type(
models.TunnelGroup,
fields='__all__',
filters=TunnelGroupFilter,
@ -41,7 +47,7 @@ class TunnelGroupType(ContactsMixin, OrganizationalObjectType):
tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.TunnelTermination,
fields='__all__',
filters=TunnelTerminationFilter,
@ -53,7 +59,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
outside_ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
@strawberry_django.type(
@register_type(
models.Tunnel,
fields='__all__',
filters=TunnelFilter,
@ -67,7 +73,7 @@ class TunnelType(ContactsMixin, PrimaryObjectType):
terminations: list[Annotated["TunnelTerminationType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.IKEProposal,
fields='__all__',
filters=IKEProposalFilter,
@ -77,7 +83,7 @@ class IKEProposalType(PrimaryObjectType):
ike_policies: list[Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.IKEPolicy,
fields='__all__',
filters=IKEPolicyFilter,
@ -88,7 +94,7 @@ class IKEPolicyType(PrimaryObjectType):
ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.IPSecProposal,
fields='__all__',
filters=IPSecProposalFilter,
@ -98,7 +104,7 @@ class IPSecProposalType(PrimaryObjectType):
ipsec_policies: list[Annotated["IPSecPolicyType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.IPSecPolicy,
fields='__all__',
filters=IPSecPolicyFilter,
@ -109,7 +115,7 @@ class IPSecPolicyType(PrimaryObjectType):
ipsec_profiles: list[Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.IPSecProfile,
fields='__all__',
filters=IPSecProfileFilter,
@ -122,7 +128,7 @@ class IPSecProfileType(PrimaryObjectType):
tunnels: list[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
@strawberry_django.type(
@register_type(
models.L2VPN,
fields='__all__',
filters=L2VPNFilter,
@ -136,7 +142,7 @@ class L2VPNType(ContactsMixin, PrimaryObjectType):
import_targets: list[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]]
@strawberry_django.type(
@register_type(
models.L2VPNTermination,
exclude=['assigned_object_type', 'assigned_object_id'],
filters=L2VPNTerminationFilter,

View File

@ -7,7 +7,7 @@ from strawberry_django import BaseFilterLookup, StrFilterLookup
from dcim.graphql.filter_mixins import ScopedFilterMixin
from netbox.graphql.filter_mixins import DistanceFilterMixin
from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter
from netbox.graphql.filters import NestedGroupModelFilter, PrimaryModelFilter, register_filter
from tenancy.graphql.filter_mixins import TenancyFilterMixin
from wireless import models
@ -26,12 +26,12 @@ __all__ = (
)
@strawberry_django.filter_type(models.WirelessLANGroup, lookups=True)
@register_filter(models.WirelessLANGroup, lookups=True)
class WirelessLANGroupFilter(NestedGroupModelFilter):
pass
@strawberry_django.filter_type(models.WirelessLAN, lookups=True)
@register_filter(models.WirelessLAN, lookups=True)
class WirelessLANFilter(
WirelessAuthenticationFilterMixin,
ScopedFilterMixin,
@ -50,7 +50,7 @@ class WirelessLANFilter(
vlan_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.WirelessLink, lookups=True)
@register_filter(models.WirelessLink, lookups=True)
class WirelessLinkFilter(
WirelessAuthenticationFilterMixin,
DistanceFilterMixin,

Some files were not shown because too many files have changed in this diff Show More