mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-31 14:18:15 +01:00
Compare commits
83 Commits
20911-drop
...
21300-cach
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49d58bb1a | ||
|
|
aa4a9da955 | ||
|
|
5c6fc2fb6f | ||
|
|
ad29cb2d66 | ||
|
|
bec5ecf6a9 | ||
|
|
c98f55dbd2 | ||
|
|
359179fd4a | ||
|
|
c44e8606f7 | ||
|
|
8e620ef325 | ||
|
|
1526e437f1 | ||
|
|
0b507eb207 | ||
|
|
5a36e79215 | ||
|
|
2a0f26623b | ||
|
|
1a603981b2 | ||
|
|
245495b2fe | ||
|
|
8d3eb69055 | ||
|
|
7e3b60f194 | ||
|
|
5338c842b8 | ||
|
|
9186b0edaa | ||
|
|
d883be9e56 | ||
|
|
6fc7fa6c64 | ||
|
|
3a33df0e43 | ||
|
|
433f46746e | ||
|
|
8f5f91fcfe | ||
|
|
1a2175127e | ||
|
|
e859807d1d | ||
|
|
a8c997ff29 | ||
|
|
4a28ab98f4 | ||
|
|
3636d55017 | ||
|
|
aa69e96818 | ||
|
|
1745d2ae93 | ||
|
|
e097a848dc | ||
|
|
595be6dcd4 | ||
|
|
a9e50238eb | ||
|
|
a9a300197a | ||
|
|
3dcca73ecc | ||
|
|
cedbeb7b19 | ||
|
|
a45b6b170d | ||
|
|
4b4c542dce | ||
|
|
077d9b1129 | ||
|
|
e81ccb9be6 | ||
|
|
bc83d04c8f | ||
|
|
42ecf3cac0 | ||
|
|
339ad455e4 | ||
|
|
af8e53d8fb | ||
|
|
f24376cfab | ||
|
|
47d4ae29c1 | ||
|
|
8fce672682 | ||
|
|
f776b97415 | ||
|
|
3cc1f30287 | ||
|
|
6d166aa10d | ||
|
|
040a2ae9a9 | ||
|
|
39f11f28fb | ||
|
|
62b9025a9e | ||
|
|
21091f22e6 | ||
|
|
3efa23cf8f | ||
|
|
0f62137957 | ||
|
|
7858ccb712 | ||
|
|
6b7b38ee0a | ||
|
|
c8f17e06a2 | ||
|
|
edace6aff4 | ||
|
|
586bc132b6 | ||
|
|
52a2b934a0 | ||
|
|
3d1f18d6dd | ||
|
|
3e2a26984f | ||
|
|
f5f0c19860 | ||
|
|
8da9b11ab8 | ||
|
|
ca67fa9999 | ||
|
|
eff768192e | ||
|
|
1e297d55ee | ||
|
|
fdb987ef91 | ||
|
|
b5a23db43c | ||
|
|
366b69aff7 | ||
|
|
c3e8c5e69c | ||
|
|
b55f36469d | ||
|
|
1c46215cd5 | ||
|
|
7fded2fd87 | ||
|
|
0ddc5805c4 | ||
|
|
c1bbc026e2 | ||
|
|
8cbfe94fba | ||
|
|
fff99fd3ff | ||
|
|
f4892caa51 | ||
|
|
a54ad24b47 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.10
|
||||
placeholder: v4.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.10
|
||||
placeholder: v4.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: 🏁 Performance
|
||||
type: Performance
|
||||
description: An opportunity to improve application performance
|
||||
labels: ["netbox", "type: performance", "status: needs triage"]
|
||||
body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Python Version
|
||||
description: What version of Python are you currently running?
|
||||
options:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Area(s) of Concern
|
||||
description: Which application interface(s) are affected?
|
||||
options:
|
||||
- label: User Interface
|
||||
- label: REST API
|
||||
- label: GraphQL API
|
||||
- label: Python ORM
|
||||
- label: Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: >
|
||||
Describe in detail the operations being performed and the indications of a performance issue.
|
||||
Include any relevant testing parameters, benchmarks, and expected results.
|
||||
validations:
|
||||
required: true
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -30,13 +30,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
3132
contrib/openapi.json
3132
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -3,29 +3,41 @@
|
||||
NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
|
||||
|
||||
```
|
||||
./manage.py nbshell
|
||||
cd /opt/netbox
|
||||
source /opt/netbox/venv/bin/activate
|
||||
python3 netbox/manage.py nbshell
|
||||
```
|
||||
|
||||
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
|
||||
```
|
||||
$ ./manage.py nbshell
|
||||
(venv) $ python3 netbox/manage.py nbshell
|
||||
### NetBox interactive shell (localhost)
|
||||
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
|
||||
### lsmodels() will show available models. Use help(<model>) for more info.
|
||||
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
|
||||
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
|
||||
```
|
||||
|
||||
The function `lsmodels()` will print a list of all available NetBox models:
|
||||
|
||||
```
|
||||
>>> lsmodels()
|
||||
DCIM:
|
||||
ConsolePort
|
||||
ConsolePortTemplate
|
||||
ConsoleServerPort
|
||||
ConsoleServerPortTemplate
|
||||
Device
|
||||
...
|
||||
DCIM:
|
||||
dcim.Cable
|
||||
dcim.CableTermination
|
||||
dcim.ConsolePort
|
||||
dcim.ConsolePortTemplate
|
||||
dcim.ConsoleServerPort
|
||||
dcim.ConsoleServerPortTemplate
|
||||
dcim.Device
|
||||
...
|
||||
```
|
||||
|
||||
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
|
||||
|
||||
```
|
||||
>>> exit()
|
||||
(venv) $
|
||||
```
|
||||
|
||||
!!! warning
|
||||
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
|
||||
>>> Device.objects.filter(interfaces__name="em0")
|
||||
```
|
||||
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
|
||||
|
||||
```
|
||||
>>> Device.objects.filter(name__icontains="testdevice")
|
||||
|
||||
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
"dcim.site": [
|
||||
"dcim.Site": [
|
||||
{
|
||||
"name": {
|
||||
"min_length": 5,
|
||||
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
|
||||
},
|
||||
"my_plugin.validators.Validator1"
|
||||
],
|
||||
"dcim.device": [
|
||||
"dcim.Device": [
|
||||
"my_plugin.validators.Validator1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "Case-Insensitive Model Names"
|
||||
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
|
||||
|
||||
---
|
||||
|
||||
## FIELD_CHOICES
|
||||
@@ -53,6 +56,9 @@ FIELD_CHOICES = {
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "Case-Insensitive Field Identifiers"
|
||||
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
|
||||
|
||||
The following model fields support configurable choices:
|
||||
|
||||
* `circuits.Circuit.status`
|
||||
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
|
||||
|
||||
```python
|
||||
PROTECTION_RULES = {
|
||||
"dcim.site": [
|
||||
"dcim.Site": [
|
||||
{
|
||||
"status": {
|
||||
"eq": "decommissioning"
|
||||
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "Case-Insensitive Model Names"
|
||||
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
|
||||
|
||||
@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
|
||||
|
||||
## Dynamic Configuration Parameters
|
||||
|
||||
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
|
||||
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > System > Configuration History). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
|
||||
|
||||
* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
|
||||
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
|
||||
|
||||
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
|
||||
|
||||
```no-highlight
|
||||
$ psql --username netbox --password --host localhost netbox
|
||||
Password for user netbox:
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
Password:
|
||||
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
netbox=> \conninfo
|
||||
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
netbox=> \q
|
||||
```
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
!!! note
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Enumerating objects: 996, done.
|
||||
remote: Counting objects: 100% (996/996), done.
|
||||
remote: Compressing objects: 100% (935/935), done.
|
||||
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
|
||||
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
||||
Resolving deltas: 100% (148/148), done.
|
||||
remote: Enumerating objects: 148317, done.
|
||||
remote: Counting objects: 100% (183/183), done.
|
||||
remote: Compressing objects: 100% (115/115), done.
|
||||
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
|
||||
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
|
||||
Resolving deltas: 100% (116428/116428), done.
|
||||
```
|
||||
|
||||
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
|
||||
@@ -102,7 +102,8 @@ sudo cp configuration_example.py configuration.py
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
|
||||
|
||||
* `ALLOWED_HOSTS`
|
||||
* `DATABASES` (or `DATABASE`)
|
||||
* `API_TOKEN_PEPPERS`
|
||||
* `DATABASES`
|
||||
* `REDIS`
|
||||
* `SECRET_KEY`
|
||||
|
||||
@@ -158,7 +159,7 @@ DATABASES = {
|
||||
|
||||
### REDIS
|
||||
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
|
||||
Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
|
||||
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
|
||||
|
||||
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
|
||||
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
|
||||
|
||||
```no-highlight
|
||||
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
|
||||
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
If successful, you should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
Watching for file changes with StatReloader
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
August 30, 2021 - 18:02:23
|
||||
Django version 3.2.6, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
January 26, 2026 - 17:00:00
|
||||
Django version 5.2.10, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
|
||||
@@ -43,16 +43,22 @@ You should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
|
||||
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
|
||||
Docs: https://docs.netbox.dev/
|
||||
Main PID: 1140492 (gunicorn)
|
||||
Tasks: 19 (limit: 4683)
|
||||
Memory: 666.2M
|
||||
Main PID: 7283 (gunicorn)
|
||||
Tasks: 6 (limit: 4545)
|
||||
Memory: 556.1M (peak: 556.3M)
|
||||
CPU: 3.387s
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
└─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
|
||||
Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
</div>
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
1. [Redis](2-redis.md)
|
||||
2. [Redis](2-redis.md)
|
||||
3. [NetBox components](3-netbox.md)
|
||||
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
|
||||
5. [HTTP server](5-http-server.md)
|
||||
|
||||
@@ -65,7 +65,7 @@ Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
# Set $NEWVER to the NetBox version being installed
|
||||
NEWVER=3.5.0
|
||||
NEWVER=4.5.0
|
||||
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
|
||||
sudo tar -xzf v$NEWVER.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
|
||||
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
||||
|
||||
```no-highlight
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
OLDVER=3.4.9
|
||||
OLDVER=4.4.10
|
||||
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
|
||||
```
|
||||
cd /opt/netbox && \
|
||||
sudo git fetch --tags && \
|
||||
sudo git checkout v4.2.7
|
||||
sudo git checkout v4.5.0
|
||||
```
|
||||
|
||||
## 4. Run the Upgrade Script
|
||||
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
|
||||
```
|
||||
|
||||
!!! warning
|
||||
If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
|
||||
If the default version of Python is not **at least 3.12**, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
|
||||
|
||||
```no-highlight
|
||||
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
|
||||
|
||||
@@ -133,23 +133,67 @@ The field "class_type" is an easy way to distinguish what type of object it is w
|
||||
|
||||
## Pagination
|
||||
|
||||
Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below:
|
||||
The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
|
||||
|
||||
The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
|
||||
|
||||
To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
|
||||
|
||||
!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
|
||||
|
||||
Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
|
||||
|
||||
### Offset Pagination
|
||||
|
||||
The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: { offset: 0, limit: 20 }) {
|
||||
device_list(pagination: {offset: 0, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {offset: 20, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor Pagination
|
||||
|
||||
Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {start: 0, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
|
||||
|
||||
For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {start: 124, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will return up to 20 records with an ID greater than or equal to 124.
|
||||
|
||||
## Authentication
|
||||
|
||||
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
|
||||
|
||||
```
|
||||
Authorization: Token $TOKEN
|
||||
```
|
||||
NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
|
||||
|
||||
## Disabling the GraphQL API
|
||||
|
||||
|
||||
@@ -215,9 +215,51 @@ http://netbox/api/ipam/ip-addresses/ \
|
||||
|
||||
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
|
||||
|
||||
### Brief Format
|
||||
### Specifying Fields
|
||||
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
|
||||
A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response.
|
||||
|
||||
```
|
||||
GET /api/dcim/sites/?fields=id,name,status,region
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "DM-NYC",
|
||||
"status": {
|
||||
"value": "active",
|
||||
"label": "Active"
|
||||
},
|
||||
"region": {
|
||||
"id": 43,
|
||||
"url": "http://netbox:8000/api/dcim/regions/43/",
|
||||
"display": "New York",
|
||||
"name": "New York",
|
||||
"slug": "us-ny",
|
||||
"description": "",
|
||||
"site_count": 0,
|
||||
"_depth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
|
||||
|
||||
```
|
||||
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
|
||||
```
|
||||
|
||||
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
|
||||
|
||||
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
|
||||
|
||||
!!! note
|
||||
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
|
||||
|
||||
#### Brief Format
|
||||
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/prefixes/13980/
|
||||
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
|
||||
}
|
||||
```
|
||||
|
||||
The brief format is much more terse:
|
||||
The brief format includes only a few fields:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/prefixes/13980/?brief=1
|
||||
GET /api/ipam/prefixes/13980/?brief=true
|
||||
```
|
||||
|
||||
```json
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
* [#20912](https://github.com/netbox-community/netbox/issues/20912) - Fix inconsistent clearing of `module` field on ModuleBay
|
||||
* [#20944](https://github.com/netbox-community/netbox/issues/20944) - Ensure cached scope is updated on child objects when a parent region/site/location is changed
|
||||
* [#20948](https://github.com/netbox-community/netbox/issues/20948) - Handle the deletion of related objects with `on_delete=RESTRICT` the same as `CASCADE`
|
||||
* [#20966](https://github.com/netbox-community/netbox/issues/20966) - Fix UI rendering issue when scrolling list of object types in permissions form
|
||||
* [#20969](https://github.com/netbox-community/netbox/issues/20969) - Fix querying of front port templates by `rear_port_id`
|
||||
* [#21011](https://github.com/netbox-community/netbox/issues/21011) - Avoid writing to the database when loading active ConfigRevision
|
||||
* [#21032](https://github.com/netbox-community/netbox/issues/21032) - Avoid SQL subquery in RestrictedQuerySet where unnecessary
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
## v4.5.0 (FUTURE)
|
||||
# NetBox v4.5
|
||||
|
||||
## v4.5.1 (2026-01-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#21018](https://github.com/netbox-community/netbox/issues/21018) - Enable filtering prefixes by location/site/site group/region directly via GraphQL API
|
||||
* [#21142](https://github.com/netbox-community/netbox/issues/21142) - Enable filtering device components by site/location/rack directly via GraphQL API
|
||||
* [#21144](https://github.com/netbox-community/netbox/issues/21144) - Enable specifying a prefix length for IP addresses when utilizing the `/api/ipam/prefixes/<id>/available-ips/` REST API endpoint
|
||||
* [#21165](https://github.com/netbox-community/netbox/issues/21165) - VLAN selector should default to group (instead of site)
|
||||
* [#21178](https://github.com/netbox-community/netbox/issues/21178) - Improve consistency of rack measurements in UI
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19901](https://github.com/netbox-community/netbox/issues/19901) - Fix `RelatedObjectDoesNotExist` exception when importing modules into unnamed devices
|
||||
* [#20239](https://github.com/netbox-community/netbox/issues/20239) - Prevent shared mutable state in PluginMenuItem & PluginMenuButton
|
||||
* [#20933](https://github.com/netbox-community/netbox/issues/20933) - Fix writable `data_file` assignment for ConfigContext and ConfigContextProfile via the REST API
|
||||
* [#21039](https://github.com/netbox-community/netbox/issues/21039) - Fix support for AVIF image uploads
|
||||
* [#21050](https://github.com/netbox-community/netbox/issues/21050) - Clear device OOB IP assignments when reassigning IP addresses
|
||||
* [#21051](https://github.com/netbox-community/netbox/issues/21051) - Remove irrelevant object types from permissions form
|
||||
* [#21097](https://github.com/netbox-community/netbox/issues/21097) - Fix comparison lookups for ID filters in GraphQL API
|
||||
* [#21102](https://github.com/netbox-community/netbox/issues/21102) - Fix GraphiQL explorer UI
|
||||
* [#21117](https://github.com/netbox-community/netbox/issues/21117) - Avoid `ValueError` exception when `API_TOKEN_PEPPERS` is not defined
|
||||
* [#21118](https://github.com/netbox-community/netbox/issues/21118) - Address performance issue when saving sites with many assigned objects
|
||||
* [#21124](https://github.com/netbox-community/netbox/issues/21124) - Fix front/rear port mapping for module types
|
||||
* [#21134](https://github.com/netbox-community/netbox/issues/21134) - Fix bulk renaming for module types
|
||||
* [#21139](https://github.com/netbox-community/netbox/issues/21139) - Support `fields` parameter for job, object change, and object type REST API endpoints
|
||||
* [#21140](https://github.com/netbox-community/netbox/issues/21140) - Restore translation for object attribute labels on several UI views
|
||||
* [#21160](https://github.com/netbox-community/netbox/issues/21160) - Fix performance issue loading UI views caused by unintended `APISelect` choices resolution
|
||||
* [#21166](https://github.com/netbox-community/netbox/issues/21166) - Fix support for 32-bit ASN filtering in GraphQL API
|
||||
* [#21175](https://github.com/netbox-community/netbox/issues/21175) - Fix pending migrations warning when `DEFAULT_LANGUAGE` is set
|
||||
* [#21181](https://github.com/netbox-community/netbox/issues/21181) - Handle `AuthenticationFailed` exception when using an invalid API token to fetch media files
|
||||
* [#21213](https://github.com/netbox-community/netbox/issues/21213) - Tag weight field should be marked as required in UI forms
|
||||
* [#21231](https://github.com/netbox-community/netbox/issues/21231) - Presence of object types table should be checked only during migration (performance improvement)
|
||||
|
||||
---
|
||||
|
||||
## v4.5.0 (2026-01-06)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -34,9 +34,10 @@ __all__ = (
|
||||
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Provider
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('asn_id', name=_('ASN')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = ProviderAccount
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
|
||||
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ProviderNetwork
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'service_id', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
|
||||
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = CircuitType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('color', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Circuit
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet(
|
||||
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
|
||||
@@ -129,6 +133,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
|
||||
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
model = CircuitGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
|
||||
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = VirtualCircuitType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('color', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualCircuit
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet('type_id', 'status', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
read_only_fields = ['path', 'last_updated', 'size', 'hash']
|
||||
|
||||
@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
|
||||
model = Job
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
|
||||
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
|
||||
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
|
||||
'log_entries',
|
||||
]
|
||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.worker import Worker
|
||||
|
||||
@@ -64,7 +63,7 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.DataFileFilterSet
|
||||
|
||||
|
||||
class JobViewSet(ReadOnlyModelViewSet):
|
||||
class JobViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of job results
|
||||
"""
|
||||
@@ -73,19 +72,20 @@ class JobViewSet(ReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.JobFilterSet
|
||||
|
||||
|
||||
class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of recent changes.
|
||||
"""
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ObjectChange.objects.all()
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
def get_queryset(self):
|
||||
return ObjectChange.objects.valid_models()
|
||||
return super().get_queryset().valid_models()
|
||||
|
||||
|
||||
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ObjectTypes.
|
||||
"""
|
||||
@@ -94,6 +94,16 @@ class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
serializer_class = serializers.ObjectTypeSerializer
|
||||
filterset_class = filtersets.ObjectTypeFilterSet
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
"""
|
||||
Override initial() to skip the restrict() call since ObjectType (a ContentType proxy)
|
||||
doesn't use RestrictedQuerySet and is publicly accessible metadata.
|
||||
"""
|
||||
# Call GenericViewSet.initial() directly, skipping BaseViewSet.initial()
|
||||
# which would try to call restrict() on the queryset
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
GenericViewSet.initial(self, request, *args, **kwargs)
|
||||
|
||||
|
||||
class BaseRQViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
|
||||
@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
|
||||
choices=JobStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
queue_name = django_filters.CharFilter()
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
|
||||
fields = (
|
||||
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
|
||||
'queue_name',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -26,8 +26,9 @@ __all__ = (
|
||||
class DataSourceFilterForm(PrimaryModelFilterSetForm):
|
||||
model = DataSource
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = Job
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'status', name=_('Attributes')),
|
||||
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
|
||||
FieldSet(
|
||||
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
||||
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
|
||||
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choices=JobStatusChoices,
|
||||
required=False
|
||||
)
|
||||
queue_name = forms.CharField(
|
||||
label=_('Queue'),
|
||||
required=False
|
||||
)
|
||||
created__after = forms.DateTimeField(
|
||||
label=_('Created after'),
|
||||
required=False,
|
||||
|
||||
18
netbox/core/migrations/0021_job_queue_name.py
Normal file
18
netbox/core/migrations/0021_job_queue_name.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-27 00:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='queue_name',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -128,7 +128,9 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||
raise ValidationError({
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
|
||||
scheme='file://'
|
||||
)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -112,6 +112,12 @@ class Job(models.Model):
|
||||
verbose_name=_('job ID'),
|
||||
unique=True
|
||||
)
|
||||
queue_name = models.CharField(
|
||||
verbose_name=_('queue name'),
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text=_('Name of the queue in which this job was enqueued')
|
||||
)
|
||||
log_entries = ArrayField(
|
||||
verbose_name=_('log entries'),
|
||||
base_field=models.JSONField(
|
||||
@@ -179,11 +185,15 @@ class Job(models.Model):
|
||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
|
||||
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
|
||||
rq_job_id = str(self.job_id)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
|
||||
# Cancel the RQ job using the stored queue name
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
job = queue.fetch_job(rq_job_id)
|
||||
|
||||
if job:
|
||||
try:
|
||||
@@ -288,7 +298,8 @@ class Job(models.Model):
|
||||
scheduled=schedule_at,
|
||||
interval=interval,
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
job_id=uuid.uuid4(),
|
||||
queue_name=rq_queue_name
|
||||
)
|
||||
job.full_clean()
|
||||
job.save()
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.db import connection, models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.context import query_cache
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
from utilities.string import title
|
||||
@@ -35,6 +36,10 @@ class ObjectTypeQuerySet(models.QuerySet):
|
||||
|
||||
class ObjectTypeManager(models.Manager):
|
||||
|
||||
# TODO: Remove this in NetBox v5.0
|
||||
# Cache the result of introspection to avoid repeated queries.
|
||||
_table_exists = False
|
||||
|
||||
def get_queryset(self):
|
||||
return ObjectTypeQuerySet(self.model, using=self._db)
|
||||
|
||||
@@ -66,13 +71,21 @@ class ObjectTypeManager(models.Manager):
|
||||
"""
|
||||
from netbox.models.features import get_model_features, model_is_public
|
||||
|
||||
# Check the request cache before hitting the database
|
||||
cache = query_cache.get()
|
||||
if cache is not None:
|
||||
if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
|
||||
return ot
|
||||
|
||||
# TODO: Remove this in NetBox v5.0
|
||||
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
|
||||
# fall back to ContentType.
|
||||
if 'core_objecttype' not in connection.introspection.table_names():
|
||||
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
|
||||
ct.features = get_model_features(ct.model_class())
|
||||
return ct
|
||||
if not ObjectTypeManager._table_exists:
|
||||
if 'core_objecttype' not in connection.introspection.table_names():
|
||||
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
|
||||
ct.features = get_model_features(ct.model_class())
|
||||
return ct
|
||||
ObjectTypeManager._table_exists = True
|
||||
|
||||
if not inspect.isclass(model):
|
||||
model = model.__class__
|
||||
@@ -90,6 +103,10 @@ class ObjectTypeManager(models.Manager):
|
||||
features=get_model_features(model),
|
||||
)[0]
|
||||
|
||||
# Populate the request cache to avoid redundant lookups
|
||||
if cache is not None:
|
||||
cache['object_types'][(model._meta.model, for_concrete_model)] = ot
|
||||
|
||||
return ot
|
||||
|
||||
def get_for_models(self, *models, for_concrete_models=True):
|
||||
|
||||
@@ -18,6 +18,7 @@ from extras.events import enqueue_event
|
||||
from extras.models import Tag
|
||||
from extras.utils import run_validators
|
||||
from netbox.config import get_config
|
||||
from utilities.data import get_config_value_ci
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
|
||||
from utilities.exceptions import AbortRequest
|
||||
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
# to queueing any events for the object being deleted, in case a validation error is
|
||||
# raised, causing the deletion to fail.
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
|
||||
@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
|
||||
completed = columns.DateTimeColumn(
|
||||
verbose_name=_('Completed'),
|
||||
)
|
||||
queue_name = tables.Column(
|
||||
verbose_name=_('Queue'),
|
||||
)
|
||||
log_entries = tables.Column(
|
||||
verbose_name=_('Log Entries'),
|
||||
)
|
||||
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
|
||||
model = Job
|
||||
fields = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||
'completed', 'user', 'error', 'job_id',
|
||||
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import DataSource, ObjectType
|
||||
from core.models import DataSource, Job, ObjectType
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from dcim.models import Site, Location, Device
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
|
||||
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
|
||||
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
|
||||
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
|
||||
|
||||
|
||||
class JobTest(TestCase):
|
||||
|
||||
@patch('core.models.jobs.django_rq.get_queue')
|
||||
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
|
||||
"""
|
||||
Test that when a job is deleted, it's canceled from the correct queue.
|
||||
"""
|
||||
mock_queue = MagicMock()
|
||||
mock_rq_job = MagicMock()
|
||||
mock_queue.fetch_job.return_value = mock_rq_job
|
||||
mock_get_queue.return_value = mock_queue
|
||||
|
||||
def dummy_func(**kwargs):
|
||||
pass
|
||||
|
||||
# Enqueue a job with a custom queue name
|
||||
custom_queue = 'my_custom_queue'
|
||||
job = Job.enqueue(
|
||||
func=dummy_func,
|
||||
name='Test Job',
|
||||
queue_name=custom_queue
|
||||
)
|
||||
|
||||
# Reset mock to clear enqueue call
|
||||
mock_get_queue.reset_mock()
|
||||
|
||||
# Delete the job
|
||||
job.delete()
|
||||
|
||||
# Verify the correct queue was used for cancellation
|
||||
mock_get_queue.assert_called_with(custom_queue)
|
||||
mock_queue.fetch_job.assert_called_with(str(job.job_id))
|
||||
mock_rq_job.cancel.assert_called_once()
|
||||
|
||||
@@ -12,11 +12,12 @@ from netbox.forms import (
|
||||
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
|
||||
PrimaryModelFilterSetForm,
|
||||
)
|
||||
from netbox.forms.mixins import OwnerFilterMixin
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from tenancy.models import Tenant
|
||||
from users.models import Owner, User
|
||||
from users.models import User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import NumberWithOptions
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
||||
@@ -70,11 +71,11 @@ __all__ = (
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
'VirtualChassisFilterForm',
|
||||
'VirtualDeviceContextFilterForm'
|
||||
'VirtualDeviceContextFilterForm',
|
||||
)
|
||||
|
||||
|
||||
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
|
||||
name = forms.CharField(
|
||||
label=_('Name'),
|
||||
required=False
|
||||
@@ -157,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device Status'),
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = Region
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', name=_('Region')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -182,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = SiteGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', name=_('Site Group')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -197,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
|
||||
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Site
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||
@@ -229,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
|
||||
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = Location
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -277,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
|
||||
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = RackRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -328,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
|
||||
class RackTypeFilterForm(RackBaseFilterForm):
|
||||
model = RackType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
@@ -350,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
|
||||
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
@@ -433,9 +436,10 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
|
||||
FieldSet('status', 'role_id', name=_('Function')),
|
||||
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -451,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = RackReservation
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('status', 'user_id', name=_('Reservation')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -509,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
|
||||
model = Manufacturer
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
@@ -518,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
|
||||
class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
model = DeviceType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
|
||||
'subdevice_role', 'airflow', name=_('Hardware')
|
||||
@@ -529,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
|
||||
),
|
||||
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
@@ -652,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ModuleTypeProfile
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q')
|
||||
tag = TagFilterField(model)
|
||||
@@ -661,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ModuleType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
|
||||
'airflow', name=_('Hardware')
|
||||
@@ -671,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
'pass_through_ports', name=_('Components')
|
||||
),
|
||||
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
profile_id = DynamicModelMultipleChoiceField(
|
||||
@@ -754,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
|
||||
model = DeviceRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
config_template_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
@@ -773,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
|
||||
class PlatformFilterForm(NestedGroupModelFilterSetForm):
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -803,11 +814,12 @@ class DeviceFilterForm(
|
||||
):
|
||||
model = Device
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
|
||||
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
FieldSet(
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
@@ -996,9 +1008,10 @@ class DeviceFilterForm(
|
||||
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualDeviceContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1023,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
|
||||
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Module
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1106,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
|
||||
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualChassis
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1135,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Cable
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1224,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = PowerPanel
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||
@@ -1263,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = PowerFeed
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1390,7 +1408,7 @@ class PathEndpointFilterForm(CabledFilterForm):
|
||||
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsolePort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1398,6 +1416,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1429,7 +1448,7 @@ class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1437,6 +1456,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1468,7 +1488,7 @@ class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterFo
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1476,6 +1496,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1502,7 +1523,7 @@ class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1510,6 +1531,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1545,7 +1567,7 @@ class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
@@ -1558,6 +1580,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id')
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@@ -1716,7 +1739,7 @@ class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1724,6 +1747,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
model = FrontPort
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1759,7 +1783,7 @@ class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1767,6 +1791,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1801,13 +1826,14 @@ class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1832,13 +1858,14 @@ class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1855,7 +1882,7 @@ class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
|
||||
name=_('Attributes')
|
||||
@@ -1865,6 +1892,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
@@ -1925,7 +1953,8 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = InventoryItemRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1937,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
class MACAddressFilterForm(PrimaryModelFilterSetForm):
|
||||
model = MACAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('mac_address', name=_('Attributes')),
|
||||
FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
|
||||
mac_address = forms.CharField(
|
||||
|
||||
@@ -75,7 +75,7 @@ class ScopedForm(forms.Form):
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if self.instance and scope_type_id != self.instance.scope_type_id:
|
||||
if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
|
||||
self.initial['scope'] = None
|
||||
|
||||
else:
|
||||
@@ -140,9 +140,6 @@ class FrontPortFormMixin(forms.Form):
|
||||
widget=forms.SelectMultiple(attrs={'size': 8})
|
||||
)
|
||||
|
||||
port_mapping_model = PortMapping
|
||||
parent_field = 'device'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -203,3 +200,22 @@ class FrontPortFormMixin(forms.Form):
|
||||
using=connection,
|
||||
update_fields=None
|
||||
)
|
||||
|
||||
def _get_rear_port_choices(self, parent_filter, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the parent object (identified
|
||||
by a Q filter), excluding those assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in self.rear_port_model.objects.filter(parent_filter):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append((pair_id, pair_label))
|
||||
return choices
|
||||
|
||||
@@ -20,7 +20,9 @@ from utilities.forms.fields import (
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from utilities.forms.widgets import (
|
||||
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
|
||||
)
|
||||
from utilities.jsonschema import JSONSchemaProperty
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
|
||||
},
|
||||
)
|
||||
)
|
||||
face = forms.ChoiceField(
|
||||
label=_('Face'),
|
||||
choices=add_blank_choice(DeviceFaceChoices),
|
||||
required=False,
|
||||
widget=ClearableSelect(
|
||||
requires_fields=['rack']
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
label=_('Device type'),
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -733,10 +743,9 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
|
||||
)
|
||||
module_bay = DynamicModelChoiceField(
|
||||
label=_('Module bay'),
|
||||
queryset=ModuleBay.objects.order_by('name'),
|
||||
queryset=ModuleBay.objects.all(),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'ordering': 'name',
|
||||
'device_id': '$device'
|
||||
},
|
||||
context={
|
||||
'disabled': 'installed_module',
|
||||
@@ -1125,9 +1134,8 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
),
|
||||
)
|
||||
|
||||
# Override FrontPortFormMixin attrs
|
||||
port_mapping_model = PortTemplateMapping
|
||||
parent_field = 'device_type'
|
||||
rear_port_model = RearPortTemplate
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
@@ -1138,13 +1146,14 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rear port choices based on parent DeviceType or ModuleType
|
||||
if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
|
||||
device_type = DeviceType.objects.get(pk=device_type_id)
|
||||
parent_filter = Q(device_type=device_type_id)
|
||||
elif module_type_id := self.data.get('module_type') or self.initial.get('module_type'):
|
||||
parent_filter = Q(module_type=module_type_id)
|
||||
else:
|
||||
return
|
||||
|
||||
# Populate rear port choices
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance)
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
|
||||
|
||||
# Set initial rear port mappings
|
||||
if self.instance.pk:
|
||||
@@ -1153,27 +1162,6 @@ class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
|
||||
]
|
||||
|
||||
def _get_rear_port_choices(self, device_type, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the device type, excluding
|
||||
those assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in device_type.port_mappings.exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in RearPortTemplate.objects.filter(device_type=device_type):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append(
|
||||
(pair_id, pair_label)
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
class RearPortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
@@ -1620,6 +1608,9 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
),
|
||||
)
|
||||
|
||||
port_mapping_model = PortMapping
|
||||
rear_port_model = RearPort
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
@@ -1630,13 +1621,12 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Populate rear port choices
|
||||
if device_id := self.data.get('device') or self.initial.get('device'):
|
||||
device = Device.objects.get(pk=device_id)
|
||||
parent_filter = Q(device=device_id)
|
||||
else:
|
||||
return
|
||||
|
||||
# Populate rear port choices
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
|
||||
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
|
||||
|
||||
# Set initial rear port mappings
|
||||
if self.instance.pk:
|
||||
@@ -1645,27 +1635,6 @@ class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
|
||||
]
|
||||
|
||||
def _get_rear_port_choices(self, device, front_port):
|
||||
"""
|
||||
Return a list of choices representing each available rear port & position pair on the device, excluding those
|
||||
assigned to the specified instance.
|
||||
"""
|
||||
occupied_rear_port_positions = [
|
||||
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
|
||||
for mapping in device.port_mappings.exclude(front_port=front_port.pk)
|
||||
]
|
||||
|
||||
choices = []
|
||||
for rear_port in RearPort.objects.filter(device=device):
|
||||
for i in range(1, rear_port.positions + 1):
|
||||
pair_id = f'{rear_port.pk}:{i}'
|
||||
if pair_id not in occupied_rear_port_positions:
|
||||
pair_label = f'{rear_port.name}:{i}'
|
||||
choices.append(
|
||||
(pair_id, pair_label)
|
||||
)
|
||||
return choices
|
||||
|
||||
|
||||
class RearPortForm(ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.rendering import FieldSet, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect
|
||||
@@ -271,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
from netbox.graphql.filter_lookups import IntegerLookup
|
||||
from extras.graphql.filters import ConfigTemplateFilter
|
||||
from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
|
||||
from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
|
||||
from .filters import *
|
||||
|
||||
__all__ = (
|
||||
@@ -35,6 +36,20 @@ class ScopedFilterMixin:
|
||||
)
|
||||
scope_id: ID | None = strawberry_django.filter_field()
|
||||
|
||||
# Cached relations
|
||||
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='location')
|
||||
)
|
||||
_region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='region')
|
||||
)
|
||||
_site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='site_group')
|
||||
)
|
||||
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='site')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentModelFilterMixin:
|
||||
|
||||
@@ -550,6 +550,10 @@ class InterfaceFilter(
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def cabled(self, value: bool, prefix: str):
|
||||
return Q(**{f'{prefix}cable__isnull': (not value)})
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def connected(self, queryset, value: bool, prefix: str):
|
||||
if value is True:
|
||||
@@ -889,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.RackType, lookups=True)
|
||||
class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
|
||||
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
|
||||
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
@@ -734,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
|
||||
filters=RackTypeFilter,
|
||||
pagination=True
|
||||
)
|
||||
class RackTypeType(PrimaryObjectType):
|
||||
class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
|
||||
rack_count: BigInt
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.utils import update_interface_bridges
|
||||
from dcim.utils import create_port_mappings, update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ImageAttachmentsMixin
|
||||
@@ -155,6 +155,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
'description': self.description,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
'airflow': self.airflow,
|
||||
'attribute_data': self.attribute_data,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
@@ -359,5 +361,7 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
update_fields=update_fields
|
||||
)
|
||||
|
||||
# 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
|
||||
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
|
||||
|
||||
@@ -122,7 +122,7 @@ class RackBase(WeightMixin, PrimaryModel):
|
||||
abstract = True
|
||||
|
||||
|
||||
class RackType(RackBase):
|
||||
class RackType(ImageAttachmentsMixin, RackBase):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a Location.
|
||||
|
||||
@@ -211,12 +211,16 @@ def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
# Bulk update cached fields to avoid O(N) performance issues with large datasets.
|
||||
# This does not trigger post_save signals, avoiding spurious change log entries.
|
||||
objects_to_update = []
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
obj.save(update_fields=[
|
||||
'_location',
|
||||
'_site',
|
||||
'_site_group',
|
||||
'_region',
|
||||
])
|
||||
objects_to_update.append(obj)
|
||||
|
||||
if objects_to_update:
|
||||
model.objects.bulk_update(
|
||||
objects_to_update,
|
||||
['_location', '_site', '_site_group', '_region']
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ __all__ = (
|
||||
'DeviceTable',
|
||||
'FrontPortTable',
|
||||
'InterfaceTable',
|
||||
'InterfaceLAGMemberTable',
|
||||
'InventoryItemRoleTable',
|
||||
'InventoryItemTable',
|
||||
'MACAddressTable',
|
||||
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
class InterfaceLAGMemberTable(PathEndpointTable, NetBoxTable):
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
accessor=Accessor('device'),
|
||||
linkify=True,
|
||||
)
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True,
|
||||
order_by=('_name',),
|
||||
)
|
||||
connection = columns.TemplateColumn(
|
||||
accessor='connected_endpoints',
|
||||
template_code=INTERFACE_LAG_MEMBERS_LINKTERMINATION,
|
||||
verbose_name=_('Peer'),
|
||||
orderable=False,
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Interface
|
||||
fields = ('pk', 'parent', 'name', 'type', 'connection')
|
||||
default_columns = ('pk', 'parent', 'name', 'type', 'connection')
|
||||
|
||||
|
||||
class DeviceInterfaceTable(InterfaceTable):
|
||||
name = tables.TemplateColumn(
|
||||
verbose_name=_('Name'),
|
||||
|
||||
@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
|
||||
{% else %}""" + LINKTERMINATION + """{% endif %}
|
||||
"""
|
||||
|
||||
INTERFACE_LAG_MEMBERS_LINKTERMINATION = """
|
||||
{% for termination in value %}
|
||||
{% if termination.parent_object %}
|
||||
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
|
||||
{% if termination.lag %}
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
<a href="{{ termination.lag.get_absolute_url }}">{{ termination.lag }}</a>
|
||||
<span class="text-muted">(LAG)</span>
|
||||
{% endif %}
|
||||
{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
{{ ''|placeholder }}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% load helpers %}
|
||||
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}
|
||||
|
||||
@@ -875,6 +875,142 @@ class ModuleBayTestCase(TestCase):
|
||||
self.assertIsNone(bay2.parent)
|
||||
self.assertIsNone(bay2.module)
|
||||
|
||||
def test_module_installation_creates_port_mappings(self):
|
||||
"""
|
||||
Test that installing a module with front/rear port templates correctly
|
||||
creates PortMapping instances for the device.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 1')
|
||||
|
||||
# Create a module type with a rear port template
|
||||
module_type_with_mappings = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module Type With Mappings',
|
||||
)
|
||||
|
||||
# Create a rear port template with 12 positions (splice)
|
||||
rear_port_template = RearPortTemplate.objects.create(
|
||||
module_type=module_type_with_mappings,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_SPLICE,
|
||||
positions=12,
|
||||
)
|
||||
|
||||
# Create 12 front port templates mapped to the rear port
|
||||
front_port_templates = []
|
||||
for i in range(1, 13):
|
||||
front_port_template = FrontPortTemplate.objects.create(
|
||||
module_type=module_type_with_mappings,
|
||||
name=f'port {i}',
|
||||
type=PortTypeChoices.TYPE_LC,
|
||||
positions=1,
|
||||
)
|
||||
front_port_templates.append(front_port_template)
|
||||
|
||||
# Create port template mapping
|
||||
PortTemplateMapping.objects.create(
|
||||
device_type=None,
|
||||
module_type=module_type_with_mappings,
|
||||
front_port=front_port_template,
|
||||
front_port_position=1,
|
||||
rear_port=rear_port_template,
|
||||
rear_port_position=i,
|
||||
)
|
||||
|
||||
# Install the module
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type_with_mappings,
|
||||
status=ModuleStatusChoices.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
# Verify that front ports were created
|
||||
front_ports = FrontPort.objects.filter(device=device, module=module)
|
||||
self.assertEqual(front_ports.count(), 12)
|
||||
|
||||
# Verify that the rear port was created
|
||||
rear_ports = RearPort.objects.filter(device=device, module=module)
|
||||
self.assertEqual(rear_ports.count(), 1)
|
||||
rear_port = rear_ports.first()
|
||||
self.assertEqual(rear_port.positions, 12)
|
||||
|
||||
# Verify that port mappings were created
|
||||
port_mappings = PortMapping.objects.filter(front_port__module=module)
|
||||
self.assertEqual(port_mappings.count(), 12)
|
||||
|
||||
# Verify each mapping is correct
|
||||
for i, front_port_template in enumerate(front_port_templates, start=1):
|
||||
front_port = FrontPort.objects.get(
|
||||
device=device,
|
||||
name=front_port_template.name,
|
||||
module=module,
|
||||
)
|
||||
|
||||
# Check that a mapping exists for this front port
|
||||
mapping = PortMapping.objects.get(
|
||||
device=device,
|
||||
front_port=front_port,
|
||||
front_port_position=1,
|
||||
)
|
||||
|
||||
self.assertEqual(mapping.rear_port, rear_port)
|
||||
self.assertEqual(mapping.front_port_position, 1)
|
||||
self.assertEqual(mapping.rear_port_position, i)
|
||||
|
||||
def test_module_installation_without_mappings(self):
|
||||
"""
|
||||
Test that installing a module without port template mappings
|
||||
doesn't create any PortMapping instances.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
module_bay = ModuleBay.objects.create(device=device, name='Test Bay PortMapping 2')
|
||||
|
||||
# Create a module type without any port template mappings
|
||||
module_type_no_mappings = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module Type Without Mappings',
|
||||
)
|
||||
|
||||
# Create a rear port template
|
||||
RearPortTemplate.objects.create(
|
||||
module_type=module_type_no_mappings,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_SPLICE,
|
||||
positions=12,
|
||||
)
|
||||
|
||||
# Create front port templates but DO NOT create PortTemplateMapping rows
|
||||
for i in range(1, 13):
|
||||
FrontPortTemplate.objects.create(
|
||||
module_type=module_type_no_mappings,
|
||||
name=f'port {i}',
|
||||
type=PortTypeChoices.TYPE_LC,
|
||||
positions=1,
|
||||
)
|
||||
|
||||
# Install the module
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type_no_mappings,
|
||||
status=ModuleStatusChoices.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
# Verify no port mappings were created for this module
|
||||
port_mappings = PortMapping.objects.filter(
|
||||
device=device,
|
||||
front_port__module=module,
|
||||
front_port_position=1,
|
||||
)
|
||||
self.assertEqual(port_mappings.count(), 0)
|
||||
self.assertEqual(FrontPort.objects.filter(module=module).count(), 12)
|
||||
self.assertEqual(RearPort.objects.filter(module=module).count(), 1)
|
||||
self.assertEqual(PortMapping.objects.filter(front_port__module=module).count(), 0)
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class RackDimensionsPanel(panels.ObjectAttributesPanel):
|
||||
outer_width = attrs.NumericAttr('outer_width', unit_accessor='get_outer_unit_display')
|
||||
outer_height = attrs.NumericAttr('outer_height', unit_accessor='get_outer_unit_display')
|
||||
outer_depth = attrs.NumericAttr('outer_depth', unit_accessor='get_outer_unit_display')
|
||||
mounting_depth = attrs.TextAttr('mounting_depth', format_string='{}mm')
|
||||
mounting_depth = attrs.TextAttr('mounting_depth', format_string=_('{} millimeters'))
|
||||
|
||||
|
||||
class RackNumberingPanel(panels.ObjectAttributesPanel):
|
||||
|
||||
@@ -85,13 +85,13 @@ def update_interface_bridges(device, interface_templates, module=None):
|
||||
interface.save()
|
||||
|
||||
|
||||
def create_port_mappings(device, device_type, module=None):
|
||||
def create_port_mappings(device, device_or_module_type, module=None):
|
||||
"""
|
||||
Replicate all front/rear port mappings from a DeviceType to the given device.
|
||||
Replicate all front/rear port mappings from a DeviceType or ModuleType to the given device.
|
||||
"""
|
||||
from dcim.models import FrontPort, PortMapping, RearPort
|
||||
|
||||
templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port')
|
||||
templates = device_or_module_type.port_mappings.prefetch_related('front_port', 'rear_port')
|
||||
|
||||
# Cache front & rear ports for efficient lookups by name
|
||||
front_ports = {
|
||||
|
||||
@@ -880,6 +880,7 @@ class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
panels.RackWeightPanel(title=_('Weight'), exclude=['total_weight']),
|
||||
CustomFieldsPanel(),
|
||||
RelatedObjectsPanel(),
|
||||
ImageAttachmentsPanel(),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1845,6 +1846,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
class ModuleTypeBulkRenameView(generic.BulkRenameView):
|
||||
queryset = ModuleType.objects.all()
|
||||
filterset = filtersets.ModuleTypeFilterSet
|
||||
field_name = 'model'
|
||||
|
||||
|
||||
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
|
||||
@@ -3134,6 +3136,14 @@ class InterfaceView(generic.ObjectView):
|
||||
)
|
||||
child_interfaces_table.configure(request)
|
||||
|
||||
# Get LAG interfaces
|
||||
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
|
||||
lag_interfaces_table = tables.InterfaceLAGMemberTable(
|
||||
lag_interfaces,
|
||||
orderable=False
|
||||
)
|
||||
lag_interfaces_table.configure(request)
|
||||
|
||||
# Get assigned VLANs and annotate whether each is tagged or untagged
|
||||
vlans = []
|
||||
if instance.untagged_vlan is not None:
|
||||
@@ -3163,6 +3173,7 @@ class InterfaceView(generic.ObjectView):
|
||||
'bridge_interfaces': bridge_interfaces,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'lag_interfaces_table': lag_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
'vlan_translation_table': vlan_translation_table,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.fields import Field
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
from extras.models import CustomField
|
||||
@@ -25,8 +24,7 @@ class CustomFieldDefaultValues:
|
||||
self.model = serializer_field.parent.Meta.model
|
||||
|
||||
# Retrieve the CustomFields for the parent model
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
fields = CustomField.objects.filter(object_types=object_type)
|
||||
fields = CustomField.objects.get_for_model(self.model)
|
||||
|
||||
# Populate the default value for each CustomField
|
||||
value = {}
|
||||
@@ -47,8 +45,7 @@ class CustomFieldsDataField(Field):
|
||||
Cache CustomFields assigned to this model to avoid redundant database queries
|
||||
"""
|
||||
if not hasattr(self, '_custom_fields'):
|
||||
object_type = ObjectType.objects.get_for_model(self.parent.Meta.model)
|
||||
self._custom_fields = CustomField.objects.filter(object_types=object_type)
|
||||
self._custom_fields = CustomField.objects.get_for_model(self.parent.Meta.model)
|
||||
return self._custom_fields
|
||||
|
||||
def to_representation(self, obj):
|
||||
|
||||
@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
|
||||
)
|
||||
data_file = DataFileSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -4,6 +4,17 @@ from extras.choices import LogLevelChoices
|
||||
# Custom fields
|
||||
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
|
||||
|
||||
# ImageAttachment
|
||||
IMAGE_ATTACHMENT_IMAGE_FORMATS = {
|
||||
'avif': 'image/avif',
|
||||
'bmp': 'image/bmp',
|
||||
'gif': 'image/gif',
|
||||
'jpeg': 'image/jpeg',
|
||||
'jpg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
}
|
||||
|
||||
# Template Export
|
||||
DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
|
||||
|
||||
|
||||
@@ -75,10 +75,11 @@ def get_bookmarks_object_type_choices():
|
||||
def get_models_from_content_types(content_types):
|
||||
"""
|
||||
Return a list of models corresponding to the given content types, identified by natural key.
|
||||
Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
|
||||
"""
|
||||
models = []
|
||||
for content_type_id in content_types:
|
||||
app_label, model_name = content_type_id.split('.')
|
||||
app_label, model_name = content_type_id.lower().split('.')
|
||||
try:
|
||||
content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
|
||||
if content_type.model_class():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections import UserDict, defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@@ -12,7 +12,6 @@ from core.models import ObjectType
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from netbox.models.features import has_feature
|
||||
from users.models import User
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.request import copy_safe_request
|
||||
from utilities.rqworker import get_rq_retry
|
||||
@@ -23,6 +22,21 @@ from .models import EventRule
|
||||
logger = logging.getLogger('netbox.events_processor')
|
||||
|
||||
|
||||
class EventContext(UserDict):
|
||||
"""
|
||||
A custom dictionary that automatically serializes its associated object on demand.
|
||||
"""
|
||||
|
||||
# We're emulating a dictionary here (rather than using a custom class) because prior to NetBox v4.5.2, events were
|
||||
# queued as dictionaries for processing by handles in EVENTS_PIPELINE. We need to avoid introducing any breaking
|
||||
# changes until a suitable minor release.
|
||||
def __getitem__(self, item):
|
||||
if item == 'data' and 'data' not in self:
|
||||
data = serialize_for_event(self['object'])
|
||||
self.__setitem__('data', data)
|
||||
return super().__getitem__(item)
|
||||
|
||||
|
||||
def serialize_for_event(instance):
|
||||
"""
|
||||
Return a serialized representation of the given instance suitable for use in a queued event.
|
||||
@@ -37,18 +51,26 @@ def serialize_for_event(instance):
|
||||
|
||||
|
||||
def get_snapshots(instance, event_type):
|
||||
snapshots = {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': None,
|
||||
}
|
||||
if event_type != OBJECT_DELETED:
|
||||
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
|
||||
if hasattr(instance, 'serialize_object'):
|
||||
snapshots['postchange'] = instance.serialize_object()
|
||||
else:
|
||||
snapshots['postchange'] = serialize_object(instance)
|
||||
"""
|
||||
Return a dictionary of pre- and post-change snapshots for the given instance.
|
||||
"""
|
||||
if event_type == OBJECT_DELETED:
|
||||
# Post-change snapshot must be empty for deleted objects
|
||||
postchange_snapshot = None
|
||||
elif hasattr(instance, '_postchange_snapshot'):
|
||||
# Use the cached post-change snapshot if one is available
|
||||
postchange_snapshot = instance._postchange_snapshot
|
||||
elif hasattr(instance, 'serialize_object'):
|
||||
# Use model's serialize_object() method if defined
|
||||
postchange_snapshot = instance.serialize_object()
|
||||
else:
|
||||
# Fall back to the serialize_object() utility function
|
||||
postchange_snapshot = serialize_object(instance)
|
||||
|
||||
return snapshots
|
||||
return {
|
||||
'prechange': getattr(instance, '_prechange_snapshot', None),
|
||||
'postchange': postchange_snapshot,
|
||||
}
|
||||
|
||||
|
||||
def enqueue_event(queue, instance, request, event_type):
|
||||
@@ -66,37 +88,42 @@ def enqueue_event(queue, instance, request, event_type):
|
||||
assert instance.pk is not None
|
||||
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||
if key in queue:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
|
||||
# If the object is being deleted, update any prior "update" event to "delete"
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['event_type'] = event_type
|
||||
else:
|
||||
queue[key] = {
|
||||
'object_type': ObjectType.objects.get_for_model(instance),
|
||||
'object_id': instance.pk,
|
||||
'event_type': event_type,
|
||||
'data': serialize_for_event(instance),
|
||||
'snapshots': get_snapshots(instance, event_type),
|
||||
'request': request,
|
||||
queue[key] = EventContext(
|
||||
object_type=ObjectType.objects.get_for_model(instance),
|
||||
object_id=instance.pk,
|
||||
object=instance,
|
||||
event_type=event_type,
|
||||
snapshots=get_snapshots(instance, event_type),
|
||||
request=request,
|
||||
user=request.user,
|
||||
# Legacy request attributes for backward compatibility
|
||||
'username': request.user.username,
|
||||
'request_id': request.id,
|
||||
}
|
||||
username=request.user.username,
|
||||
request_id=request.id,
|
||||
)
|
||||
# Force serialization of objects prior to them actually being deleted
|
||||
if event_type == OBJECT_DELETED:
|
||||
queue[key]['data'] = serialize_for_event(instance)
|
||||
|
||||
|
||||
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
|
||||
user = User.objects.get(username=username) if username else None
|
||||
def process_event_rules(event_rules, object_type, event):
|
||||
"""
|
||||
Process a list of EventRules against an event.
|
||||
"""
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
||||
# Evaluate event rule conditions (if any)
|
||||
if not event_rule.eval_conditions(data):
|
||||
if not event_rule.eval_conditions(event['data']):
|
||||
continue
|
||||
|
||||
# Compile event data
|
||||
event_data = event_rule.action_data or {}
|
||||
event_data.update(data)
|
||||
event_data.update(event['data'])
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
@@ -109,25 +136,20 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
params = {
|
||||
"event_rule": event_rule,
|
||||
"object_type": object_type,
|
||||
"event_type": event_type,
|
||||
"event_type": event['event_type'],
|
||||
"data": event_data,
|
||||
"snapshots": snapshots,
|
||||
"snapshots": event.get('snapshots'),
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"username": username,
|
||||
"username": event['username'],
|
||||
"retry": get_rq_retry()
|
||||
}
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
if 'request' in event:
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
# which can cause pickle errors with Pillow.
|
||||
params["request"] = copy_safe_request(request, include_files=False)
|
||||
params['request'] = copy_safe_request(event['request'], include_files=False)
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
"extras.webhooks.send_webhook",
|
||||
**params
|
||||
)
|
||||
rq_queue.enqueue('extras.webhooks.send_webhook', **params)
|
||||
|
||||
# Scripts
|
||||
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
|
||||
@@ -139,16 +161,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
params = {
|
||||
"instance": event_rule.action_object,
|
||||
"name": script.name,
|
||||
"user": user,
|
||||
"user": event['user'],
|
||||
"data": event_data
|
||||
}
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
params["request"] = copy_safe_request(request)
|
||||
ScriptJob.enqueue(
|
||||
**params
|
||||
)
|
||||
if 'snapshots' in event:
|
||||
params['snapshots'] = event['snapshots']
|
||||
if 'request' in event:
|
||||
params['request'] = copy_safe_request(event['request'])
|
||||
|
||||
# Enqueue the job
|
||||
ScriptJob.enqueue(**params)
|
||||
|
||||
# Notification groups
|
||||
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
|
||||
@@ -157,7 +179,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
object_type=object_type,
|
||||
object_id=event_data['id'],
|
||||
object_repr=event_data.get('display'),
|
||||
event_type=event_type
|
||||
event_type=event['event_type']
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -169,6 +191,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
def process_event_queue(events):
|
||||
"""
|
||||
Flush a list of object representation to RQ for EventRule processing.
|
||||
|
||||
This is the default processor listed in EVENTS_PIPELINE.
|
||||
"""
|
||||
events_cache = defaultdict(dict)
|
||||
|
||||
@@ -188,11 +212,7 @@ def process_event_queue(events):
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=object_type,
|
||||
event_type=event['event_type'],
|
||||
data=event['data'],
|
||||
username=event['username'],
|
||||
snapshots=event['snapshots'],
|
||||
request=event['request'],
|
||||
event=event,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -271,10 +271,6 @@ class EventRuleImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
|
||||
class TagImportForm(OwnerCSVMixin, CSVModelForm):
|
||||
slug = SlugField()
|
||||
weight = forms.IntegerField(
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
object_types = CSVMultipleContentTypeField(
|
||||
label=_('Object types'),
|
||||
queryset=ObjectType.objects.with_feature('tags'),
|
||||
|
||||
@@ -7,13 +7,12 @@ from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.events import get_event_type_choices
|
||||
from netbox.forms import NetBoxModelFilterSetForm, PrimaryModelFilterSetForm
|
||||
from netbox.forms.mixins import SavedFiltersMixin
|
||||
from netbox.forms.mixins import OwnerFilterMixin, SavedFiltersMixin
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, Owner, User
|
||||
from users.models import Group, User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
TagFilterField,
|
||||
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import DateTimePicker
|
||||
@@ -39,7 +38,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class CustomFieldFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = CustomField
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
@@ -47,6 +46,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
|
||||
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
|
||||
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('custom_fields'),
|
||||
@@ -119,18 +119,14 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
label=_('Validation regex'),
|
||||
required=False
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class CustomFieldChoiceSetFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = CustomFieldChoiceSet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('base_choices', 'choice', name=_('Choices')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
base_choices = forms.MultipleChoiceField(
|
||||
choices=CustomFieldChoiceSetBaseChoices,
|
||||
@@ -139,18 +135,14 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choice = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class CustomLinkFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = CustomLink
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
label=_('Object types'),
|
||||
@@ -175,19 +167,15 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class ExportTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = ExportTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'object_type_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
@@ -226,11 +214,6 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
@@ -250,11 +233,12 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class SavedFilterFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = SavedFilter
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
label=_('Object types'),
|
||||
@@ -279,11 +263,6 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
|
||||
@@ -317,11 +296,12 @@ class TableConfigFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class WebhookFilterForm(NetBoxModelFilterSetForm):
|
||||
class WebhookFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
|
||||
model = Webhook
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('payload_url', 'http_method', 'http_content_type', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
http_content_type = forms.CharField(
|
||||
label=_('HTTP content type'),
|
||||
@@ -336,19 +316,15 @@ class WebhookFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('HTTP method')
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class EventRuleFilterForm(NetBoxModelFilterSetForm):
|
||||
class EventRuleFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
|
||||
model = EventRule
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('object_type_id', 'event_type', 'action_type', 'enabled', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
object_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('event_rules'),
|
||||
@@ -372,16 +348,16 @@ class EventRuleFilterForm(NetBoxModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class TagFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = Tag
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('content_type_id', 'for_object_type_id', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ObjectType.objects.with_feature('tags'),
|
||||
required=False,
|
||||
@@ -392,11 +368,6 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
required=False,
|
||||
label=_('Allowed object type')
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
@@ -404,6 +375,7 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
@@ -420,16 +392,17 @@ class ConfigContextProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class ConfigContextFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('profile', name=_('Config Context')),
|
||||
FieldSet('profile_id', name=_('Config Context')),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')),
|
||||
FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant'))
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
profile_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigContextProfile.objects.all(),
|
||||
@@ -514,19 +487,15 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
required=False,
|
||||
label=_('Tags')
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
class ConfigTemplateFilterForm(OwnerFilterMixin, SavedFiltersMixin, FilterForm):
|
||||
model = ConfigTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
|
||||
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
|
||||
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
@@ -568,11 +537,6 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class LocalConfigContextFilterForm(forms.Form):
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from core.models import ObjectType
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from netbox.events import get_event_type_choices
|
||||
@@ -177,6 +178,13 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
|
||||
) + ' <code>choice1:First Choice</code>')
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||
name=_('Custom Field Choice Set')
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically', 'owner')
|
||||
@@ -570,10 +578,6 @@ class TagForm(ChangelogMessageMixin, OwnerMixin, forms.ModelForm):
|
||||
queryset=ObjectType.objects.with_feature('tags'),
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
|
||||
@@ -784,8 +788,11 @@ class ImageAttachmentForm(forms.ModelForm):
|
||||
fields = [
|
||||
'image', 'name', 'description',
|
||||
]
|
||||
help_texts = {
|
||||
'name': _("If no name is specified, the file name will be used.")
|
||||
# Explicitly set 'image/avif' to support AVIF selection in Firefox
|
||||
widgets = {
|
||||
'image': forms.ClearableFileInput(
|
||||
attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.data import CHOICE_SETS
|
||||
from netbox.context import query_cache
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin
|
||||
from netbox.models.mixins import OwnerMixin
|
||||
@@ -58,8 +59,20 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
"""
|
||||
Return all CustomFields assigned to the given model.
|
||||
"""
|
||||
# Check the request cache before hitting the database
|
||||
cache = query_cache.get()
|
||||
if cache is not None:
|
||||
if custom_fields := cache['custom_fields'].get(model._meta.model):
|
||||
return custom_fields
|
||||
|
||||
content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
|
||||
return self.get_queryset().filter(object_types=content_type)
|
||||
custom_fields = self.get_queryset().filter(object_types=content_type)
|
||||
|
||||
# Populate the request cache to avoid redundant lookups
|
||||
if cache is not None:
|
||||
cache['custom_fields'][model._meta.model] = custom_fields
|
||||
|
||||
return custom_fields
|
||||
|
||||
def get_defaults_for_model(self, model):
|
||||
"""
|
||||
|
||||
@@ -137,7 +137,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
module = self.get_module()
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
||||
logger.error(f"Failed to load script: {self.python_name} error: {e}")
|
||||
module = None
|
||||
|
||||
scripts = {}
|
||||
|
||||
@@ -61,7 +61,7 @@ class ScriptVariable:
|
||||
self.field_attrs['label'] = label
|
||||
if description:
|
||||
self.field_attrs['help_text'] = description
|
||||
if default:
|
||||
if default is not None:
|
||||
self.field_attrs['initial'] = default
|
||||
if widget:
|
||||
self.field_attrs['widget'] = widget
|
||||
|
||||
@@ -4,11 +4,12 @@ from django.dispatch import receiver
|
||||
|
||||
from core.events import *
|
||||
from core.signals import job_end, job_start
|
||||
from extras.events import process_event_rules
|
||||
from extras.events import EventContext, process_event_rules
|
||||
from extras.models import EventRule, Notification, Subscription
|
||||
from netbox.config import get_config
|
||||
from netbox.models.features import has_feature
|
||||
from netbox.signals import post_clean
|
||||
from utilities.data import get_config_value_ci
|
||||
from utilities.exceptions import AbortRequest
|
||||
from .models import CustomField, TaggedItem
|
||||
from .utils import run_validators
|
||||
@@ -65,7 +66,7 @@ def run_save_validators(sender, instance, **kwargs):
|
||||
Run any custom validation rules for the model prior to calling save().
|
||||
"""
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
|
||||
validators = get_config_value_ci(get_config().CUSTOM_VALIDATORS, model_name, default=[])
|
||||
|
||||
run_validators(instance, validators)
|
||||
|
||||
@@ -102,14 +103,12 @@ def process_job_start_event_rules(sender, **kwargs):
|
||||
enabled=True,
|
||||
object_types=sender.object_type
|
||||
)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event = EventContext(
|
||||
event_type=JOB_STARTED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
user=sender.user,
|
||||
)
|
||||
process_event_rules(event_rules, sender.object_type, event)
|
||||
|
||||
|
||||
@receiver(job_end)
|
||||
@@ -122,14 +121,12 @@ def process_job_end_event_rules(sender, **kwargs):
|
||||
enabled=True,
|
||||
object_types=sender.object_type
|
||||
)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(
|
||||
event_rules=event_rules,
|
||||
object_type=sender.object_type,
|
||||
event = EventContext(
|
||||
event_type=JOB_COMPLETED,
|
||||
data=sender.data,
|
||||
username=username
|
||||
user=sender.user,
|
||||
)
|
||||
process_event_rules(event_rules, sender.object_type, event)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
|
||||
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
|
||||
<i class="mdi mdi-image"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
|
||||
"""
|
||||
|
||||
NOTIFICATION_ICON = """
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@@ -7,7 +8,7 @@ from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
from core.models import ObjectType
|
||||
from core.models import DataFile, DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
@@ -731,6 +732,51 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
ConfigContextProfile.objects.bulk_create(profiles)
|
||||
|
||||
def test_update_data_source_and_data_file(self):
|
||||
"""
|
||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
||||
|
||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
||||
"""
|
||||
self.add_permissions(
|
||||
'core.view_datafile',
|
||||
'core.view_datasource',
|
||||
'extras.view_configcontextprofile',
|
||||
'extras.change_configcontextprofile',
|
||||
)
|
||||
config_context_profile = ConfigContextProfile.objects.first()
|
||||
|
||||
# Create a data source and file
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='local',
|
||||
source_url='file:///tmp/netbox-datasource/',
|
||||
)
|
||||
# Generate a valid dummy YAML file
|
||||
file_data = b'profile: configcontext\n'
|
||||
datafile = DataFile.objects.create(
|
||||
source=datasource,
|
||||
path='dir1/file1.yml',
|
||||
last_updated=now(),
|
||||
size=len(file_data),
|
||||
hash=hashlib.sha256(file_data).hexdigest(),
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
url = self._get_detail_url(config_context_profile)
|
||||
payload = {
|
||||
'data_source': datasource.pk,
|
||||
'data_file': datafile.pk,
|
||||
}
|
||||
response = self.client.patch(url, payload, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
config_context_profile.refresh_from_db()
|
||||
self.assertEqual(config_context_profile.data_source_id, datasource.pk)
|
||||
self.assertEqual(config_context_profile.data_file_id, datafile.pk)
|
||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
||||
|
||||
|
||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigContext
|
||||
@@ -812,6 +858,51 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
rendered_context = device.get_config_context()
|
||||
self.assertEqual(rendered_context['bar'], 456)
|
||||
|
||||
def test_update_data_source_and_data_file(self):
|
||||
"""
|
||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
||||
|
||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
||||
"""
|
||||
self.add_permissions(
|
||||
'core.view_datafile',
|
||||
'core.view_datasource',
|
||||
'extras.view_configcontext',
|
||||
'extras.change_configcontext',
|
||||
)
|
||||
config_context = ConfigContext.objects.first()
|
||||
|
||||
# Create a data source and file
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='local',
|
||||
source_url='file:///tmp/netbox-datasource/',
|
||||
)
|
||||
# Generate a valid dummy YAML file
|
||||
file_data = b'context: config\n'
|
||||
datafile = DataFile.objects.create(
|
||||
source=datasource,
|
||||
path='dir1/file1.yml',
|
||||
last_updated=now(),
|
||||
size=len(file_data),
|
||||
hash=hashlib.sha256(file_data).hexdigest(),
|
||||
data=file_data,
|
||||
)
|
||||
|
||||
url = self._get_detail_url(config_context)
|
||||
payload = {
|
||||
'data_source': datasource.pk,
|
||||
'data_file': datafile.pk,
|
||||
}
|
||||
response = self.client.patch(url, payload, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
config_context.refresh_from_db()
|
||||
self.assertEqual(config_context.data_source_id, datasource.pk)
|
||||
self.assertEqual(config_context.data_file_id, datafile.pk)
|
||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
||||
|
||||
|
||||
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ConfigTemplate
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.forms import ValidationError
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from core.models import DataSource, ObjectType
|
||||
from core.models import AutoSyncRecord, DataSource, ObjectType
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
|
||||
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -754,3 +754,53 @@ class ConfigTemplateTest(TestCase):
|
||||
@tag('regression')
|
||||
def test_config_template_with_data_source_nested_templates(self):
|
||||
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
|
||||
|
||||
@tag('regression')
|
||||
def test_autosyncrecord_cleanup_on_detach(self):
|
||||
"""Test that AutoSyncRecord is deleted when detaching from DataSource."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
templates_dir = Path(temp_dir) / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._create_template_file(templates_dir, 'test.j2', 'Test content')
|
||||
|
||||
data_source = DataSource(
|
||||
name="Test DataSource for Detach",
|
||||
type="local",
|
||||
source_url=str(templates_dir),
|
||||
)
|
||||
data_source.save()
|
||||
data_source.sync()
|
||||
|
||||
data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
|
||||
|
||||
# Create a ConfigTemplate with data_file and auto_sync_enabled
|
||||
config_template = ConfigTemplate(
|
||||
name="TestTemplateForDetach",
|
||||
data_file=data_file,
|
||||
auto_sync_enabled=True
|
||||
)
|
||||
config_template.clean()
|
||||
config_template.save()
|
||||
|
||||
# Verify AutoSyncRecord was created
|
||||
object_type = ObjectType.objects.get_for_model(ConfigTemplate)
|
||||
autosync_records = AutoSyncRecord.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=config_template.pk
|
||||
)
|
||||
self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
|
||||
|
||||
# Detach from DataSource
|
||||
config_template.data_file = None
|
||||
config_template.data_source = None
|
||||
config_template.auto_sync_enabled = False
|
||||
config_template.clean()
|
||||
config_template.save()
|
||||
|
||||
# Verify AutoSyncRecord was deleted
|
||||
autosync_records = AutoSyncRecord.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=config_template.pk
|
||||
)
|
||||
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.test import tag
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.events import *
|
||||
@@ -906,7 +907,7 @@ class ScriptValidationErrorTest(TestCase):
|
||||
user_permissions = ['extras.view_script', 'extras.run_script']
|
||||
|
||||
class TestScriptMixin:
|
||||
bar = IntegerVar(min_value=0, max_value=30, default=30)
|
||||
bar = IntegerVar(min_value=0, max_value=30)
|
||||
|
||||
class TestScriptClass(TestScriptMixin, PythonClass):
|
||||
class Meta:
|
||||
@@ -930,8 +931,6 @@ class ScriptValidationErrorTest(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
def test_script_validation_error_displays_message(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
url = reverse('extras:script', kwargs={'pk': self.script.pk})
|
||||
|
||||
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
|
||||
@@ -944,8 +943,6 @@ class ScriptValidationErrorTest(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
def test_script_validation_error_no_toast_for_fieldset_fields(self):
|
||||
from unittest.mock import patch, PropertyMock
|
||||
|
||||
class FieldsetScript(PythonClass):
|
||||
class Meta:
|
||||
name = 'Fieldset test'
|
||||
@@ -967,3 +964,42 @@ class ScriptValidationErrorTest(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
messages = list(response.context['messages'])
|
||||
self.assertEqual(len(messages), 0)
|
||||
|
||||
|
||||
class ScriptDefaultValuesTest(TestCase):
|
||||
user_permissions = ['extras.view_script', 'extras.run_script']
|
||||
|
||||
class TestScriptClass(PythonClass):
|
||||
class Meta:
|
||||
name = 'Test script'
|
||||
commit_default = False
|
||||
|
||||
bool_default_true = BooleanVar(default=True)
|
||||
bool_default_false = BooleanVar(default=False)
|
||||
int_with_default = IntegerVar(default=0)
|
||||
int_without_default = IntegerVar(required=False)
|
||||
|
||||
def run(self, data, commit):
|
||||
return "Complete"
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
module = ScriptModule.objects.create(file_root=ManagedFileRootPathChoices.SCRIPTS, file_path='test_script.py')
|
||||
cls.script = Script.objects.create(module=module, name='Test script', is_executable=True)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
|
||||
|
||||
def test_default_values_are_used(self):
|
||||
url = reverse('extras:script', kwargs={'pk': self.script.pk})
|
||||
|
||||
with patch('extras.views.get_workers_for_queue', return_value=['worker']):
|
||||
with patch('extras.jobs.ScriptJob.enqueue') as mock_enqueue:
|
||||
mock_enqueue.return_value.pk = 1
|
||||
self.client.post(url, {})
|
||||
call_kwargs = mock_enqueue.call_args.kwargs
|
||||
self.assertEqual(call_kwargs['data']['bool_default_true'], True)
|
||||
self.assertEqual(call_kwargs['data']['bool_default_false'], False)
|
||||
self.assertEqual(call_kwargs['data']['int_with_default'], 0)
|
||||
self.assertIsNone(call_kwargs['data']['int_without_default'])
|
||||
|
||||
@@ -10,6 +10,7 @@ from taggit.managers import _TaggableManager
|
||||
|
||||
from netbox.context import current_request
|
||||
|
||||
from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
|
||||
from .validators import CustomValidator
|
||||
|
||||
__all__ = (
|
||||
@@ -78,7 +79,7 @@ def image_upload(instance, filename):
|
||||
"""
|
||||
upload_dir = 'image-attachments'
|
||||
default_filename = 'unnamed'
|
||||
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
||||
allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys()
|
||||
|
||||
# Normalize Windows paths and create a Path object.
|
||||
normalized_filename = str(filename).replace('\\', '/')
|
||||
|
||||
@@ -1511,7 +1511,13 @@ class ScriptView(BaseScriptView):
|
||||
'script': script,
|
||||
})
|
||||
|
||||
form = script_class.as_form(request.POST, request.FILES)
|
||||
# Populate missing variables with their default values, if defined
|
||||
post_data = request.POST.copy()
|
||||
for name, var in script_class._get_vars().items():
|
||||
if name not in post_data and (initial := var.field_attrs.get('initial')) is not None:
|
||||
post_data[name] = initial
|
||||
|
||||
form = script_class.as_form(post_data, request.FILES)
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not get_workers_for_queue('default'):
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..field_serializers import IPAddressField, IPNetworkField
|
||||
__all__ = (
|
||||
'AggregateSerializer',
|
||||
'AvailableIPSerializer',
|
||||
'AvailableIPRequestSerializer',
|
||||
'AvailablePrefixSerializer',
|
||||
'IPAddressSerializer',
|
||||
'IPRangeSerializer',
|
||||
@@ -147,6 +148,43 @@ class IPRangeSerializer(PrimaryModelSerializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class AvailableIPRequestSerializer(serializers.Serializer):
|
||||
"""
|
||||
Request payload for creating IP addresses from the available-ips endpoint.
|
||||
"""
|
||||
prefix_length = serializers.IntegerField(required=False)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
data = super().to_internal_value(data)
|
||||
|
||||
prefix_length = data.get('prefix_length')
|
||||
if prefix_length is None:
|
||||
# No override requested; the parent prefix/range mask length will be used.
|
||||
return data
|
||||
|
||||
parent = self.context.get('parent')
|
||||
if parent is None:
|
||||
return data
|
||||
|
||||
# Validate the requested prefix length
|
||||
if prefix_length < parent.mask_length:
|
||||
raise serializers.ValidationError({
|
||||
'prefix_length': 'Prefix length must be greater than or equal to the parent mask length ({})'.format(
|
||||
parent.mask_length
|
||||
)
|
||||
})
|
||||
elif parent.family == 4 and prefix_length > 32:
|
||||
raise serializers.ValidationError({
|
||||
'prefix_length': 'Invalid prefix length ({}) for IPv6'.format(prefix_length)
|
||||
})
|
||||
elif parent.family == 6 and prefix_length > 128:
|
||||
raise serializers.ValidationError({
|
||||
'prefix_length': 'Invalid prefix length ({}) for IPv4'.format(prefix_length)
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class IPAddressSerializer(PrimaryModelSerializer):
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
address = IPAddressField()
|
||||
|
||||
@@ -400,7 +400,7 @@ class AvailablePrefixesView(AvailableObjectsView):
|
||||
class AvailableIPAddressesView(AvailableObjectsView):
|
||||
queryset = IPAddress.objects.all()
|
||||
read_serializer_class = serializers.AvailableIPSerializer
|
||||
write_serializer_class = serializers.AvailableIPSerializer
|
||||
write_serializer_class = serializers.AvailableIPRequestSerializer
|
||||
advisory_lock_key = 'available-ips'
|
||||
|
||||
def get_available_objects(self, parent, limit=None):
|
||||
@@ -421,8 +421,9 @@ class AvailableIPAddressesView(AvailableObjectsView):
|
||||
def prep_object_data(self, requested_objects, available_objects, parent):
|
||||
available_ips = iter(available_objects)
|
||||
for i, request_data in enumerate(requested_objects):
|
||||
prefix_length = request_data.pop('prefix_length', None) or parent.mask_length
|
||||
request_data.update({
|
||||
'address': f'{next(available_ips)}/{parent.mask_length}',
|
||||
'address': f'{next(available_ips)}/{prefix_length}',
|
||||
'vrf': parent.vrf.pk if parent.vrf else None,
|
||||
})
|
||||
|
||||
@@ -435,7 +436,7 @@ class AvailableIPAddressesView(AvailableObjectsView):
|
||||
@extend_schema(
|
||||
methods=["post"],
|
||||
responses={201: serializers.IPAddressSerializer(many=True)},
|
||||
request=serializers.IPAddressSerializer(many=True),
|
||||
request=serializers.AvailableIPRequestSerializer(many=True),
|
||||
)
|
||||
def post(self, request, pk):
|
||||
return super().post(request, pk)
|
||||
|
||||
@@ -45,9 +45,10 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
|
||||
class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VRF
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('import_target_id', 'export_target_id', name=_('Route Targets')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
import_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
@@ -65,9 +66,10 @@ class VRFFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = RouteTarget
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('importing_vrf_id', 'exporting_vrf_id', name=_('VRF')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
importing_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -85,8 +87,9 @@ class RouteTargetFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class RIRFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = RIR
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('is_private', name=_('RIR')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
is_private = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -101,9 +104,10 @@ class RIRFilterForm(OrganizationalModelFilterSetForm):
|
||||
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Aggregate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'rir_id', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
@@ -122,9 +126,10 @@ class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
|
||||
class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
model = ASNRange
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('rir_id', 'start', 'end', name=_('Range')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
@@ -145,9 +150,10 @@ class ASNRangeFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = ASN
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
@@ -170,7 +176,8 @@ class ASNFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class RoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = Role
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -178,7 +185,7 @@ class RoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Prefix
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
||||
name=_('Addressing')
|
||||
@@ -187,6 +194,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
@@ -284,9 +292,10 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
|
||||
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
@@ -331,14 +340,15 @@ class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelF
|
||||
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name',
|
||||
name=_('Attributes')
|
||||
),
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
@@ -409,9 +419,10 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
|
||||
class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
|
||||
model = FHRPGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'protocol', 'group_id', name=_('Attributes')),
|
||||
FieldSet('auth_type', 'auth_key', name=_('Authentication')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
name = forms.CharField(
|
||||
label=_('Name'),
|
||||
@@ -441,11 +452,12 @@ class FHRPGroupFilterForm(PrimaryModelFilterSetForm):
|
||||
|
||||
class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
|
||||
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
|
||||
FieldSet('contains_vid', name=_('VLANs')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
model = VLANGroup
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
@@ -495,8 +507,9 @@ class VLANGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
class VLANTranslationPolicyFilterForm(PrimaryModelFilterSetForm):
|
||||
model = VLANTranslationPolicy
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
name = forms.CharField(
|
||||
required=False,
|
||||
@@ -532,13 +545,14 @@ class VLANTranslationRuleFilterForm(NetBoxModelFilterSetForm):
|
||||
class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VLAN
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
|
||||
FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id')
|
||||
selector_fields = ('filter_id', 'q', 'group_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -604,8 +618,9 @@ class VLANFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ServiceTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('protocol', 'port', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
protocol = forms.ChoiceField(
|
||||
label=_('Protocol'),
|
||||
@@ -622,9 +637,10 @@ class ServiceTemplateFilterForm(PrimaryModelFilterSetForm):
|
||||
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('protocol', 'port', name=_('Attributes')),
|
||||
FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -372,8 +372,8 @@ class IPAddressForm(TenancyForm, PrimaryModelForm):
|
||||
'virtual_machine_id': instance.assigned_object.virtual_machine.pk,
|
||||
})
|
||||
|
||||
# Disable object assignment fields if the IP address is designated as primary
|
||||
if self.initial.get('primary_for_parent'):
|
||||
# Disable object assignment fields if the IP address is designated as primary or OOB
|
||||
if self.initial.get('primary_for_parent') or self.initial.get('oob_for_parent'):
|
||||
self.fields['interface'].disabled = True
|
||||
self.fields['vminterface'].disabled = True
|
||||
self.fields['fhrpgroup'].disabled = True
|
||||
|
||||
@@ -20,7 +20,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from netbox.graphql.filter_lookups import IntegerLookup, IntegerRangeArrayLookup
|
||||
from netbox.graphql.filter_lookups import BigIntegerLookup, IntegerLookup, IntegerRangeArrayLookup
|
||||
from circuits.graphql.filters import ProviderFilter
|
||||
from core.graphql.filters import ContentTypeFilter
|
||||
from dcim.graphql.filters import SiteFilter
|
||||
@@ -53,7 +53,7 @@ __all__ = (
|
||||
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()
|
||||
asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
asn: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
sites: (
|
||||
@@ -70,10 +70,10 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
|
||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
rir_id: ID | None = strawberry_django.filter_field()
|
||||
start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
start: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
end: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
|
||||
@@ -940,6 +940,13 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
||||
)
|
||||
|
||||
# can't use is_oob_ip as self.assigned_object might be changed
|
||||
if hasattr(original_parent, 'oob_ip') and original_parent.oob_ip_id == self.pk:
|
||||
if parent != original_parent:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign IP address while it is designated as the OOB IP for the parent object")
|
||||
)
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -370,6 +370,11 @@ class AnnotatedIPAddressTable(IPAddressTable):
|
||||
verbose_name=_('IP Address')
|
||||
)
|
||||
|
||||
def render_pk(self, value, record, bound_column):
|
||||
if type(record) is not self._meta.model:
|
||||
return ''
|
||||
return bound_column.column.render(value, bound_column, record)
|
||||
|
||||
class Meta(IPAddressTable.Meta):
|
||||
pass
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ PREFIX_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.scope %}&scope_type={{ object.scope_type.pk }}&scope={{ object.scope.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Interface
|
||||
from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin, TenantColumn
|
||||
@@ -159,11 +160,26 @@ class VLANDevicesTable(VLANMembersTable):
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('edit',)
|
||||
)
|
||||
link_peer = columns.TemplateColumn(
|
||||
accessor='link_peers',
|
||||
template_code=LINKTERMINATION,
|
||||
orderable=False,
|
||||
verbose_name=_('Link Peers'),
|
||||
)
|
||||
|
||||
# Override PathEndpointTable.connection to accommodate virtual circuits
|
||||
connection = columns.TemplateColumn(
|
||||
accessor='_path__destinations',
|
||||
template_code=INTERFACE_LINKTERMINATION,
|
||||
orderable=False,
|
||||
verbose_name=_('Connection'),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Interface
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
exclude = ('id', )
|
||||
fields = ('device', 'name', 'link_peer', 'connection', 'tagged', 'actions')
|
||||
default_columns = ('device', 'name', 'connection', 'tagged', 'actions')
|
||||
exclude = ('id',)
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
|
||||
@@ -595,6 +595,31 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
def test_create_available_ip_with_mask(self):
|
||||
"""
|
||||
Test the creation of an available IP address with a specific prefix length.
|
||||
"""
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_ipaddress')
|
||||
|
||||
# Create an available IP with a specific prefix length
|
||||
data = {
|
||||
'prefix_length': 32,
|
||||
'description': 'Test IP 1',
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['address'], '192.0.2.1/32')
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
# Attempt to create an available IP with a prefix length less than its parent prefix
|
||||
data = {
|
||||
'prefix_length': 23, # Prefix is a /24
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@tag('regression')
|
||||
def test_graphql_tenant_prefixes_contains_nested_skips_invalid(self):
|
||||
"""
|
||||
|
||||
41
netbox/ipam/tests/test_tables.py
Normal file
41
netbox/ipam/tests/test_tables.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from ipam.models import IPAddress, IPRange, Prefix
|
||||
from ipam.tables import AnnotatedIPAddressTable
|
||||
from ipam.utils import annotate_ip_space
|
||||
|
||||
|
||||
class AnnotatedIPAddressTableTest(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.prefix = Prefix.objects.create(
|
||||
prefix=IPNetwork('10.1.1.0/24'),
|
||||
status='active'
|
||||
)
|
||||
|
||||
cls.ip_address = IPAddress.objects.create(
|
||||
address='10.1.1.1/24',
|
||||
status='active'
|
||||
)
|
||||
|
||||
cls.ip_range = IPRange.objects.create(
|
||||
start_address=IPNetwork('10.1.1.2/24'),
|
||||
end_address=IPNetwork('10.1.1.10/24'),
|
||||
status='active'
|
||||
)
|
||||
|
||||
def test_ipaddress_has_checkbox_iprange_does_not(self):
|
||||
data = annotate_ip_space(self.prefix)
|
||||
table = AnnotatedIPAddressTable(data, orderable=False)
|
||||
table.columns.show('pk')
|
||||
|
||||
request = RequestFactory().get('/')
|
||||
html = table.as_html(request)
|
||||
|
||||
ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"')
|
||||
self.assertEqual(ipaddress_checkbox_count, 1)
|
||||
|
||||
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
|
||||
self.assertEqual(iprange_checkbox_count, 0)
|
||||
@@ -49,6 +49,9 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
|
||||
if prefix_list and show_available:
|
||||
|
||||
# Find all unallocated space, add fake Prefix objects to child_prefixes.
|
||||
# IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
|
||||
# saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
|
||||
# due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
|
||||
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
|
||||
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
|
||||
child_prefixes = child_prefixes + available_prefixes
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from functools import cached_property
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.utils.serializer_helpers import BindingDict
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from utilities.api import get_related_object_by_attrs
|
||||
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
|
||||
@@ -19,16 +18,18 @@ class BaseModelSerializer(serializers.ModelSerializer):
|
||||
display_url = NetBoxURLHyperlinkedIdentityField()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def __init__(self, *args, nested=False, fields=None, **kwargs):
|
||||
def __init__(self, *args, nested=False, fields=None, omit=None, **kwargs):
|
||||
"""
|
||||
Extends the base __init__() method to support dynamic fields.
|
||||
|
||||
:param nested: Set to True if this serializer is being employed within a parent serializer
|
||||
:param fields: An iterable of fields to include when rendering the serialized object, If nested is
|
||||
True but no fields are specified, Meta.brief_fields will be used.
|
||||
:param omit: An iterable of fields to omit from the serialized object
|
||||
"""
|
||||
self.nested = nested
|
||||
self._requested_fields = fields
|
||||
self._include_fields = fields or []
|
||||
self._omit_fields = omit or []
|
||||
|
||||
# Disable validators for nested objects (which already exist)
|
||||
if self.nested:
|
||||
@@ -36,8 +37,8 @@ class BaseModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
# If this serializer is nested but no fields have been specified,
|
||||
# default to using Meta.brief_fields (if set)
|
||||
if self.nested and not fields:
|
||||
self._requested_fields = getattr(self.Meta, 'brief_fields', None)
|
||||
if self.nested and not fields and not omit:
|
||||
self._include_fields = getattr(self.Meta, 'brief_fields', None)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -54,16 +55,19 @@ class BaseModelSerializer(serializers.ModelSerializer):
|
||||
@cached_property
|
||||
def fields(self):
|
||||
"""
|
||||
Override the fields property to check for requested fields. If defined,
|
||||
return only the applicable fields.
|
||||
Override the fields property to return only specifically requested fields if needed.
|
||||
"""
|
||||
if not self._requested_fields:
|
||||
return super().fields
|
||||
fields = super().fields
|
||||
|
||||
# Include only requested fields
|
||||
if self._include_fields:
|
||||
for field_name in set(fields) - set(self._include_fields):
|
||||
fields.pop(field_name, None)
|
||||
|
||||
# Remove omitted fields
|
||||
for field_name in set(self._omit_fields):
|
||||
fields.pop(field_name, None)
|
||||
|
||||
fields = BindingDict(self)
|
||||
for key, value in self.get_fields().items():
|
||||
if key in self._requested_fields:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
|
||||
@@ -5,13 +5,13 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import router, transaction
|
||||
from django.db.models import ProtectedError, RestrictedError
|
||||
from django_pglocks import advisory_lock
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from rest_framework import mixins as drf_mixins
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from netbox.api.serializers.features import ChangeLogMessageSerializer
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.query import reapply_model_ordering
|
||||
@@ -59,33 +59,38 @@ class BaseViewSet(GenericViewSet):
|
||||
serializer_class = self.get_serializer_class()
|
||||
|
||||
# Dynamically resolve prefetches for included serializer fields and attach them to the queryset
|
||||
if prefetch := get_prefetches_for_serializer(serializer_class, fields_to_include=self.requested_fields):
|
||||
if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs):
|
||||
qs = qs.prefetch_related(*prefetch)
|
||||
|
||||
# Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset
|
||||
if annotations := get_annotations_for_serializer(serializer_class, fields_to_include=self.requested_fields):
|
||||
if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs):
|
||||
qs = qs.annotate(**annotations)
|
||||
|
||||
return qs
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
# If specific fields have been requested, pass them to the serializer
|
||||
if self.requested_fields:
|
||||
kwargs['fields'] = self.requested_fields
|
||||
|
||||
# Pass the fields/omit kwargs (if specified by the request) to the serializer
|
||||
kwargs.update(**self.field_kwargs)
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def requested_fields(self):
|
||||
def field_kwargs(self):
|
||||
"""Return a dictionary of keyword arguments to be passed when instantiating the serializer."""
|
||||
# An explicit list of fields was requested
|
||||
if requested_fields := self.request.query_params.get('fields'):
|
||||
return requested_fields.split(',')
|
||||
return {'fields': requested_fields.split(',')}
|
||||
|
||||
# An explicit list of fields to omit was requested
|
||||
if omit_fields := self.request.query_params.get('omit'):
|
||||
return {'omit': omit_fields.split(',')}
|
||||
|
||||
# Brief mode has been enabled for this request
|
||||
elif self.brief:
|
||||
if self.brief:
|
||||
serializer_class = self.get_serializer_class()
|
||||
return getattr(serializer_class.Meta, 'brief_fields', None)
|
||||
return None
|
||||
if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None):
|
||||
return {'fields': brief_fields}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class NetBoxReadOnlyModelViewSet(
|
||||
@@ -165,6 +170,28 @@ class NetBoxModelViewSet(
|
||||
|
||||
# Creates
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
bulk_create = getattr(serializer, 'many', False)
|
||||
self.perform_create(serializer)
|
||||
|
||||
# After creating the instance(s), re-initialize the serializer with a queryset
|
||||
# to ensure related objects are prefetched.
|
||||
if bulk_create:
|
||||
instance_pks = [obj.pk for obj in serializer.instance]
|
||||
# Order by PK to ensure that the ordering of objects in the response
|
||||
# matches the ordering of those in the request.
|
||||
qs = self.get_queryset().filter(pk__in=instance_pks).order_by('pk')
|
||||
else:
|
||||
qs = self.get_queryset().get(pk=serializer.instance.pk)
|
||||
|
||||
# Re-serialize the instance(s) with prefetched data
|
||||
serializer = self.get_serializer(qs, many=bulk_create)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
model = self.queryset.model
|
||||
logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
|
||||
@@ -181,9 +208,20 @@ class NetBoxModelViewSet(
|
||||
# Updates
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Hotwire get_object() to ensure we save a pre-change snapshot
|
||||
self.get_object = self.get_object_with_snapshot
|
||||
return super().update(request, *args, **kwargs)
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object_with_snapshot()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
|
||||
# After updating the instance, re-initialize the serializer with a queryset
|
||||
# to ensure related objects are prefetched.
|
||||
qs = self.get_queryset().get(pk=serializer.instance.pk)
|
||||
|
||||
# Re-serialize the instance(s) with prefetched data
|
||||
serializer = self.get_serializer(qs)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
model = self.queryset.model
|
||||
|
||||
@@ -108,13 +108,17 @@ class BulkUpdateModelMixin:
|
||||
obj.pop('id'): obj for obj in request.data
|
||||
}
|
||||
|
||||
data = self.perform_bulk_update(qs, update_data, partial=partial)
|
||||
object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
# Prefetch related objects for all updated instances
|
||||
qs = self.get_queryset().filter(pk__in=object_pks)
|
||||
serializer = self.get_serializer(qs, many=True)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_bulk_update(self, objects, update_data, partial):
|
||||
updated_pks = []
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
data_list = []
|
||||
for obj in objects:
|
||||
data = update_data.get(obj.id)
|
||||
if hasattr(obj, 'snapshot'):
|
||||
@@ -122,9 +126,9 @@ class BulkUpdateModelMixin:
|
||||
serializer = self.get_serializer(obj, data=data, partial=partial)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
data_list.append(serializer.data)
|
||||
updated_pks.append(obj.pk)
|
||||
|
||||
return data_list
|
||||
return updated_pks
|
||||
|
||||
def bulk_partial_update(self, request, *args, **kwargs):
|
||||
kwargs['partial'] = True
|
||||
|
||||
@@ -3,8 +3,10 @@ from contextvars import ContextVar
|
||||
__all__ = (
|
||||
'current_request',
|
||||
'events_queue',
|
||||
'query_cache',
|
||||
)
|
||||
|
||||
|
||||
current_request = ContextVar('current_request', default=None)
|
||||
events_queue = ContextVar('events_queue', default=dict())
|
||||
query_cache = ContextVar('query_cache', default=None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.context import current_request, events_queue, query_cache
|
||||
from netbox.utils import register_request_processor
|
||||
from extras.events import flush_events
|
||||
|
||||
@@ -16,6 +17,7 @@ def event_tracking(request):
|
||||
"""
|
||||
current_request.set(request)
|
||||
events_queue.set({})
|
||||
query_cache.set(defaultdict(dict))
|
||||
|
||||
yield
|
||||
|
||||
@@ -26,3 +28,4 @@ def event_tracking(request):
|
||||
# Clear context vars
|
||||
current_request.set(None)
|
||||
events_queue.set({})
|
||||
query_cache.set(None)
|
||||
|
||||
@@ -306,11 +306,10 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Dynamically add a Filter for each CustomField applicable to the parent model
|
||||
custom_fields = CustomField.objects.filter(
|
||||
object_types=ContentType.objects.get_for_model(self._meta.model)
|
||||
).exclude(
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
)
|
||||
custom_fields = [
|
||||
cf for cf in CustomField.objects.get_for_model(self._meta.model)
|
||||
if cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
]
|
||||
|
||||
custom_field_filters = {}
|
||||
for custom_field in custom_fields:
|
||||
|
||||
@@ -31,10 +31,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(
|
||||
object_types=content_type,
|
||||
ui_editable=CustomFieldUIEditableChoices.YES
|
||||
)
|
||||
return [
|
||||
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
|
||||
if cf.ui_editable == CustomFieldUIEditableChoices.YES
|
||||
]
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import *
|
||||
from users.models import Owner
|
||||
from utilities.forms.fields import DynamicModelChoiceField, QueryField
|
||||
from utilities.forms.fields import QueryField
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin
|
||||
from .mixins import CustomFieldsMixin, OwnerFilterMixin, SavedFiltersMixin
|
||||
|
||||
__all__ = (
|
||||
'NestedGroupModelFilterSetForm',
|
||||
@@ -36,10 +34,12 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return super()._get_custom_fields(content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
||||
)
|
||||
return [
|
||||
cf for cf in super()._get_custom_fields(content_type) if (
|
||||
cf.filter_logic != CustomFieldFilterLogicChoices.FILTER_DISABLED and
|
||||
cf.type != CustomFieldTypeChoices.TYPE_JSON
|
||||
)
|
||||
]
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(
|
||||
@@ -47,14 +47,6 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
|
||||
)
|
||||
|
||||
|
||||
class OwnerFilterMixin(forms.Form):
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class PrimaryModelFilterSetForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
|
||||
"""
|
||||
FilterSet form for models which inherit from PrimaryModel.
|
||||
|
||||
@@ -4,13 +4,14 @@ from django.utils.translation import gettext as _
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from users.models import Owner
|
||||
from users.models import OwnerGroup, Owner
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
|
||||
__all__ = (
|
||||
'ChangelogMessageMixin',
|
||||
'CustomFieldsMixin',
|
||||
'OwnerMixin',
|
||||
'OwnerFilterMixin',
|
||||
'SavedFiltersMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
@@ -22,7 +23,7 @@ class ChangelogMessageMixin(forms.Form):
|
||||
"""
|
||||
changelog_message = forms.CharField(
|
||||
required=False,
|
||||
max_length=200
|
||||
max_length=200,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -42,6 +43,7 @@ class CustomFieldsMixin:
|
||||
Attributes:
|
||||
model: The model class
|
||||
"""
|
||||
|
||||
model = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -63,9 +65,10 @@ class CustomFieldsMixin:
|
||||
return ObjectType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(object_types=content_type).exclude(
|
||||
ui_editable=CustomFieldUIEditableChoices.HIDDEN
|
||||
)
|
||||
return [
|
||||
cf for cf in CustomField.objects.get_for_model(content_type.model_class())
|
||||
if cf.ui_editable != CustomFieldUIEditableChoices.HIDDEN
|
||||
]
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field()
|
||||
@@ -86,13 +89,20 @@ class CustomFieldsMixin:
|
||||
|
||||
|
||||
class SavedFiltersMixin(forms.Form):
|
||||
"""
|
||||
Form mixin for forms that support saved filters.
|
||||
|
||||
Provides a field for selecting a saved filter,
|
||||
with options limited to those applicable to the form's model.
|
||||
"""
|
||||
|
||||
filter_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SavedFilter.objects.all(),
|
||||
required=False,
|
||||
label=_('Saved Filter'),
|
||||
query_params={
|
||||
'usable': True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -107,6 +117,13 @@ class SavedFiltersMixin(forms.Form):
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
"""
|
||||
Mixin for forms that support tagging.
|
||||
|
||||
Provides a field for selecting tags,
|
||||
with options limited to those applicable to the form's model.
|
||||
"""
|
||||
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
@@ -124,10 +141,47 @@ class TagsMixin(forms.Form):
|
||||
|
||||
class OwnerMixin(forms.Form):
|
||||
"""
|
||||
Add an `owner` field to forms for models which support Owner assignment.
|
||||
Mixin for forms which adds ownership fields.
|
||||
|
||||
Include this mixin in forms for models which
|
||||
support owner and/or owner group assignment.
|
||||
"""
|
||||
|
||||
owner_group = DynamicModelChoiceField(
|
||||
label=_('Owner group'),
|
||||
queryset=OwnerGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
initial_params={'members': '$owner'},
|
||||
)
|
||||
owner = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
query_params={'group_id': '$owner_group'},
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class OwnerFilterMixin(forms.Form):
|
||||
"""
|
||||
Mixin for filterset forms which adds owner and owner group filtering.
|
||||
|
||||
Include this mixin in filterset forms for models
|
||||
which support owner and/or owner group assignment.
|
||||
"""
|
||||
|
||||
owner_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=OwnerGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Owner Group'),
|
||||
)
|
||||
owner_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'group_id': '$owner_group_id'
|
||||
},
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
@@ -19,8 +19,11 @@ from strawberry_django import (
|
||||
process_filters,
|
||||
)
|
||||
|
||||
from netbox.graphql.scalars import BigInt
|
||||
|
||||
__all__ = (
|
||||
'ArrayLookup',
|
||||
'BigIntegerLookup',
|
||||
'FloatArrayLookup',
|
||||
'FloatLookup',
|
||||
'IntegerArrayLookup',
|
||||
@@ -78,6 +81,29 @@ class IntegerLookup:
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@strawberry.input(one_of=True, description='Lookup for BigInteger fields. Only one of the lookup fields can be set.')
|
||||
class BigIntegerLookup:
|
||||
filter_lookup: FilterLookup[BigInt] | None = strawberry_django.filter_field()
|
||||
range_lookup: RangeLookup[BigInt] | None = strawberry_django.filter_field()
|
||||
comparison_lookup: ComparisonFilterLookup[BigInt] | None = strawberry_django.filter_field()
|
||||
|
||||
def get_filter(self):
|
||||
for field in self.__strawberry_definition__.fields:
|
||||
value = getattr(self, field.name, None)
|
||||
if value is not strawberry.UNSET:
|
||||
return value
|
||||
return None
|
||||
|
||||
@strawberry_django.filter_field
|
||||
def filter(self, info: Info, queryset: QuerySet, prefix: DirectiveValue[str] = '') -> Tuple[QuerySet, Q]:
|
||||
filters = self.get_filter()
|
||||
|
||||
if not filters:
|
||||
return queryset, Q()
|
||||
|
||||
return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
|
||||
|
||||
|
||||
@strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.')
|
||||
class FloatLookup:
|
||||
filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import strawberry_django
|
||||
from strawberry import ID
|
||||
from strawberry_django import FilterLookup
|
||||
from strawberry_django import ComparisonFilterLookup, FilterLookup
|
||||
|
||||
from core.graphql.filter_mixins import ChangeLoggingMixin
|
||||
from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
|
||||
@@ -23,7 +23,7 @@ __all__ = (
|
||||
|
||||
@dataclass
|
||||
class BaseModelFilter:
|
||||
id: FilterLookup[ID] | None = strawberry_django.filter_field()
|
||||
id: ComparisonFilterLookup[ID] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):
|
||||
|
||||
50
netbox/netbox/graphql/pagination.py
Normal file
50
netbox/netbox/graphql/pagination.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import strawberry
|
||||
from strawberry.types.unset import UNSET
|
||||
from strawberry_django.pagination import _QS, apply
|
||||
|
||||
__all__ = (
|
||||
'OffsetPaginationInfo',
|
||||
'OffsetPaginationInput',
|
||||
'apply_pagination',
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class OffsetPaginationInfo:
|
||||
offset: int = 0
|
||||
limit: int | None = UNSET
|
||||
start: int | None = UNSET
|
||||
|
||||
|
||||
@strawberry.input
|
||||
class OffsetPaginationInput(OffsetPaginationInfo):
|
||||
"""
|
||||
Customized implementation of OffsetPaginationInput to support cursor-based pagination.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def apply_pagination(
|
||||
self,
|
||||
queryset: _QS,
|
||||
pagination: OffsetPaginationInput | None = None,
|
||||
*,
|
||||
related_field_id: str | None = None,
|
||||
) -> _QS:
|
||||
"""
|
||||
Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
|
||||
"""
|
||||
if pagination is not None and pagination.start not in (None, UNSET):
|
||||
if pagination.offset:
|
||||
raise ValueError('Cannot specify both `start` and `offset` in pagination.')
|
||||
if pagination.start < 0:
|
||||
raise ValueError('`start` must be greater than or equal to zero.')
|
||||
|
||||
# Filter the queryset to include only records with a primary key greater than or equal to the start value,
|
||||
# and force ordering by primary key to ensure consistent pagination across all records.
|
||||
queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
|
||||
|
||||
# Ignore `offset` when `start` is set
|
||||
pagination.offset = 0
|
||||
|
||||
return apply(pagination, queryset, related_field_id=related_field_id)
|
||||
@@ -121,9 +121,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model):
|
||||
if hasattr(self, '_prechange_snapshot'):
|
||||
objectchange.prechange_data = self._prechange_snapshot
|
||||
if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE):
|
||||
objectchange.postchange_data = self.serialize_object(exclude=exclude)
|
||||
self._postchange_snapshot = self.serialize_object(exclude=exclude)
|
||||
objectchange.postchange_data = self._postchange_snapshot
|
||||
|
||||
return objectchange
|
||||
to_objectchange.alters_data = True
|
||||
|
||||
|
||||
class CloningMixin(models.Model):
|
||||
@@ -317,9 +319,11 @@ class CustomFieldsMixin(models.Model):
|
||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from extras.models import CustomField
|
||||
|
||||
# Populate default values if omitted
|
||||
for cf in self.custom_fields.filter(default__isnull=False):
|
||||
if cf.name not in self.custom_field_data:
|
||||
for cf in CustomField.objects.get_for_model(self):
|
||||
if cf.name not in self.custom_field_data and cf.default is not None:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -569,7 +573,6 @@ class SyncedDataMixin(models.Model):
|
||||
)
|
||||
else:
|
||||
AutoSyncRecord.objects.filter(
|
||||
datafile=self.data_file,
|
||||
object_type=object_type,
|
||||
object_id=self.pk
|
||||
).delete()
|
||||
@@ -582,7 +585,6 @@ class SyncedDataMixin(models.Model):
|
||||
# Delete AutoSyncRecord
|
||||
object_type = ObjectType.objects.get_for_model(self)
|
||||
AutoSyncRecord.objects.filter(
|
||||
datafile=self.data_file,
|
||||
object_type=object_type,
|
||||
object_id=self.pk
|
||||
).delete()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from functools import cache
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.registry import registry
|
||||
@@ -409,60 +411,10 @@ ADMIN_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Authentication'),
|
||||
items=(
|
||||
MenuItem(
|
||||
link='users:user_list',
|
||||
link_text=_('Users'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_user'],
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link='users:user_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['users.add_user']
|
||||
),
|
||||
MenuItemButton(
|
||||
link='users:user_bulk_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['users.add_user']
|
||||
)
|
||||
)
|
||||
),
|
||||
MenuItem(
|
||||
link='users:group_list',
|
||||
link_text=_('Groups'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_group'],
|
||||
buttons=(
|
||||
MenuItemButton(
|
||||
link='users:group_add',
|
||||
title='Add',
|
||||
icon_class='mdi mdi-plus-thick',
|
||||
permissions=['users.add_group']
|
||||
),
|
||||
MenuItemButton(
|
||||
link='users:group_bulk_import',
|
||||
title='Import',
|
||||
icon_class='mdi mdi-upload',
|
||||
permissions=['users.add_group']
|
||||
)
|
||||
)
|
||||
),
|
||||
MenuItem(
|
||||
link='users:token_list',
|
||||
link_text=_('API Tokens'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_token'],
|
||||
buttons=get_model_buttons('users', 'token')
|
||||
),
|
||||
MenuItem(
|
||||
link='users:objectpermission_list',
|
||||
link_text=_('Permissions'),
|
||||
staff_only=True,
|
||||
permissions=['users.view_objectpermission'],
|
||||
buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
|
||||
),
|
||||
get_model_item('users', 'user', _('Users')),
|
||||
get_model_item('users', 'group', _('Groups')),
|
||||
get_model_item('users', 'token', _('API Tokens')),
|
||||
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
@@ -501,40 +453,49 @@ ADMIN_MENU = Menu(
|
||||
),
|
||||
)
|
||||
|
||||
MENUS = [
|
||||
ORGANIZATION_MENU,
|
||||
RACKS_MENU,
|
||||
DEVICES_MENU,
|
||||
CONNECTIONS_MENU,
|
||||
WIRELESS_MENU,
|
||||
IPAM_MENU,
|
||||
VPN_MENU,
|
||||
VIRTUALIZATION_MENU,
|
||||
CIRCUITS_MENU,
|
||||
POWER_MENU,
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
]
|
||||
|
||||
# Add top-level plugin menus
|
||||
for menu in registry['plugins']['menus']:
|
||||
MENUS.append(menu)
|
||||
|
||||
# Add the default "plugins" menu
|
||||
if registry['plugins']['menu_items']:
|
||||
|
||||
# Build the default plugins menu
|
||||
groups = [
|
||||
MenuGroup(label=label, items=items)
|
||||
for label, items in registry['plugins']['menu_items'].items()
|
||||
@cache
|
||||
def get_menus():
|
||||
"""
|
||||
Dynamically build and return the list of navigation menus.
|
||||
This ensures plugin menus registered during app initialization are included.
|
||||
The result is cached since menus don't change without a Django restart.
|
||||
"""
|
||||
menus = [
|
||||
ORGANIZATION_MENU,
|
||||
RACKS_MENU,
|
||||
DEVICES_MENU,
|
||||
CONNECTIONS_MENU,
|
||||
WIRELESS_MENU,
|
||||
IPAM_MENU,
|
||||
VPN_MENU,
|
||||
VIRTUALIZATION_MENU,
|
||||
CIRCUITS_MENU,
|
||||
POWER_MENU,
|
||||
PROVISIONING_MENU,
|
||||
CUSTOMIZATION_MENU,
|
||||
OPERATIONS_MENU,
|
||||
]
|
||||
plugins_menu = Menu(
|
||||
label=_("Plugins"),
|
||||
icon_class="mdi mdi-puzzle",
|
||||
groups=groups
|
||||
)
|
||||
MENUS.append(plugins_menu)
|
||||
|
||||
# Add the admin menu last
|
||||
MENUS.append(ADMIN_MENU)
|
||||
# Add top-level plugin menus
|
||||
for menu in registry['plugins']['menus']:
|
||||
menus.append(menu)
|
||||
|
||||
# Add the default "plugins" menu
|
||||
if registry['plugins']['menu_items']:
|
||||
# Build the default plugins menu
|
||||
groups = [
|
||||
MenuGroup(label=label, items=items)
|
||||
for label, items in registry['plugins']['menu_items'].items()
|
||||
]
|
||||
plugins_menu = Menu(
|
||||
label=_("Plugins"),
|
||||
icon_class="mdi mdi-puzzle",
|
||||
groups=groups
|
||||
)
|
||||
menus.append(plugins_menu)
|
||||
|
||||
# Add the admin menu last
|
||||
menus.append(ADMIN_MENU)
|
||||
|
||||
return menus
|
||||
|
||||
@@ -208,9 +208,12 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
except KeyError:
|
||||
break
|
||||
|
||||
# Prefetch any associated custom fields
|
||||
# Prefetch any associated custom fields (excluding those with a zero search weight)
|
||||
object_type = ObjectType.objects.get_for_model(indexer.model)
|
||||
custom_fields = CustomField.objects.filter(object_types=object_type).exclude(search_weight=0)
|
||||
custom_fields = [
|
||||
cf for cf in CustomField.objects.get_for_model(indexer.model)
|
||||
if cf.search_weight > 0
|
||||
]
|
||||
|
||||
# Wipe out any previously cached values for the object
|
||||
if remove_existing:
|
||||
|
||||
@@ -12,10 +12,13 @@ from django.core.validators import URLValidator
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.utils import field_mapping
|
||||
from strawberry_django import pagination
|
||||
from strawberry_django.fields.field import StrawberryDjangoField
|
||||
|
||||
from core.exceptions import IncompatiblePluginError
|
||||
from netbox.config import PARAMS as CONFIG_PARAMS
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
from netbox.graphql.pagination import OffsetPaginationInput, apply_pagination
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
import storages.utils # type: ignore
|
||||
@@ -33,6 +36,12 @@ from .monkey import get_unique_validators
|
||||
# Override DRF's get_unique_validators() function with our own (see bug #19302)
|
||||
field_mapping.get_unique_validators = get_unique_validators
|
||||
|
||||
# Override strawberry-django's OffsetPaginationInput class to add the `start` parameter
|
||||
pagination.OffsetPaginationInput = OffsetPaginationInput
|
||||
|
||||
# Patch StrawberryDjangoField to use our custom `apply_pagination()` method with support for cursor-based pagination
|
||||
StrawberryDjangoField.apply_pagination = apply_pagination
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
|
||||
@@ -244,9 +244,10 @@ class NetBoxTable(BaseTable):
|
||||
|
||||
# Add custom field & custom link columns
|
||||
object_type = ObjectType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(
|
||||
object_types=object_type
|
||||
).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN)
|
||||
custom_fields = [
|
||||
cf for cf in CustomField.objects.get_for_model(self._meta.model)
|
||||
if cf.ui_visible != CustomFieldUIVisibleChoices.HIDDEN
|
||||
]
|
||||
extra_columns.extend([
|
||||
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
|
||||
])
|
||||
@@ -271,9 +272,14 @@ class NetBoxTable(BaseTable):
|
||||
|
||||
|
||||
class PrimaryModelTable(NetBoxTable):
|
||||
owner_group = tables.Column(
|
||||
accessor='owner__group',
|
||||
linkify=True,
|
||||
verbose_name=_('Owner Group'),
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Owner')
|
||||
verbose_name=_('Owner'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
@@ -281,9 +287,14 @@ class PrimaryModelTable(NetBoxTable):
|
||||
|
||||
|
||||
class OrganizationalModelTable(NetBoxTable):
|
||||
owner_group = tables.Column(
|
||||
accessor='owner__group',
|
||||
linkify=True,
|
||||
verbose_name=_('Owner Group'),
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Owner')
|
||||
verbose_name=_('Owner'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
@@ -291,9 +302,14 @@ class OrganizationalModelTable(NetBoxTable):
|
||||
|
||||
|
||||
class NestedGroupModelTable(NetBoxTable):
|
||||
owner_group = tables.Column(
|
||||
accessor='owner__group',
|
||||
linkify=True,
|
||||
verbose_name=_('Owner Group'),
|
||||
)
|
||||
owner = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Owner')
|
||||
verbose_name=_('Owner'),
|
||||
)
|
||||
name = columns.MPTTColumn(
|
||||
verbose_name=_('Name'),
|
||||
|
||||
@@ -4,10 +4,8 @@ from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import LocationStatusChoices
|
||||
from dcim.models import Site, Location
|
||||
from users.models import ObjectPermission
|
||||
from utilities.testing import disable_warnings, APITestCase, TestCase
|
||||
|
||||
|
||||
@@ -45,17 +43,28 @@ class GraphQLTestCase(TestCase):
|
||||
|
||||
class GraphQLAPITestCase(APITestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
Site(name='Site 4', slug='site-4'),
|
||||
Site(name='Site 5', slug='site-5'),
|
||||
Site(name='Site 6', slug='site-6'),
|
||||
Site(name='Site 7', slug='site-7'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@override_settings(LOGIN_REQUIRED=True)
|
||||
def test_graphql_filter_objects(self):
|
||||
"""
|
||||
Test the operation of filters for GraphQL API requests.
|
||||
"""
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
self.add_permissions('dcim.view_site', 'dcim.view_location')
|
||||
url = reverse('graphql')
|
||||
|
||||
sites = Site.objects.all()[:3]
|
||||
Location.objects.create(
|
||||
site=sites[0],
|
||||
name='Location 1',
|
||||
@@ -75,18 +84,6 @@ class GraphQLAPITestCase(APITestCase):
|
||||
status=LocationStatusChoices.STATUS_ACTIVE
|
||||
),
|
||||
|
||||
# Add object-level permission
|
||||
obj_perm = ObjectPermission(
|
||||
name='Test permission',
|
||||
actions=['view']
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
|
||||
|
||||
url = reverse('graphql')
|
||||
|
||||
# A valid request should return the filtered list
|
||||
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
|
||||
response = self.client.post(url, data={'query': query}, format="json", **self.header)
|
||||
@@ -133,10 +130,136 @@ class GraphQLAPITestCase(APITestCase):
|
||||
self.assertEqual(len(data['data']['location_list']), 0)
|
||||
|
||||
# Removing the permissions from location should result in an empty locations list
|
||||
obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
|
||||
self.remove_permissions('dcim.view_location')
|
||||
query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}'
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site']['locations']), 0)
|
||||
|
||||
def test_offset_pagination(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
|
||||
# Test `limit` only
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {limit: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
|
||||
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
|
||||
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
|
||||
|
||||
# Test `offset` only
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {offset: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 4)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
|
||||
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
|
||||
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
|
||||
self.assertEqual(data['data']['site_list'][3]['name'], 'Site 7')
|
||||
|
||||
# Test `offset` & `limit`
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {offset: 3, limit: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
|
||||
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
|
||||
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
|
||||
|
||||
def test_cursor_pagination(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
|
||||
# Page 1
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {start: 0, limit: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 1')
|
||||
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 2')
|
||||
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 3')
|
||||
|
||||
# Page 2
|
||||
start_id = int(data['data']['site_list'][-1]['id']) + 1
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 4')
|
||||
self.assertEqual(data['data']['site_list'][1]['name'], 'Site 5')
|
||||
self.assertEqual(data['data']['site_list'][2]['name'], 'Site 6')
|
||||
|
||||
# Page 3
|
||||
start_id = int(data['data']['site_list'][-1]['id']) + 1
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {start: """ + str(start_id) + """, limit: 3}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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)
|
||||
self.assertEqual(len(data['data']['site_list']), 1)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
|
||||
|
||||
def test_pagination_conflict(self):
|
||||
url = reverse('graphql')
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {start: 1, offset: 1}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
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.assertIn('errors', data)
|
||||
self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user