mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
Merge branch 'feature' into 7336-vlan-translation-feature
This commit is contained in:
commit
55a2034918
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.3
|
||||
placeholder: v4.1.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -40,7 +40,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.3
|
||||
placeholder: v4.1.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
node-version: ['18.x']
|
||||
node-version: ['20.x']
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
|
44
.pre-commit-config.yaml
Normal file
44
.pre-commit-config.yaml
Normal file
@ -0,0 +1,44 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Ruff linter"
|
||||
args: [ netbox/ ]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: django-check
|
||||
name: "Django system check"
|
||||
description: "Run Django's internal check for common problems"
|
||||
entry: python netbox/manage.py check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
- id: django-makemigrations
|
||||
name: "Django migrations check"
|
||||
description: "Check for any missing Django migrations"
|
||||
entry: python netbox/manage.py makemigrations --check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
types: [python]
|
||||
- id: mkdocs-build
|
||||
name: "Build documentation"
|
||||
description: "Build the documentation with mkdocs"
|
||||
files: 'docs/'
|
||||
entry: mkdocs build
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: yarn-validate
|
||||
name: "Yarn validate"
|
||||
description: "Check UI ESLint, TypeScript, and Prettier compliance"
|
||||
files: 'netbox/project-static/'
|
||||
entry: yarn --cwd netbox/project-static validate
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: verify-bundles
|
||||
name: "Verify static asset bundles"
|
||||
description: "Ensure that any modified static assets have been compiled"
|
||||
files: 'netbox/project-static/'
|
||||
entry: scripts/verify-bundles.sh
|
||||
language: system
|
||||
pass_filenames: false
|
@ -12,6 +12,9 @@
|
||||
"left-to-right",
|
||||
"right-to-left",
|
||||
"side-to-rear",
|
||||
"rear-to-side",
|
||||
"bottom-to-top",
|
||||
"top-to-bottom",
|
||||
"passive",
|
||||
"mixed"
|
||||
]
|
||||
|
52
docs/administration/authentication/google.md
Normal file
52
docs/administration/authentication/google.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Google
|
||||
|
||||
This guide explains how to configure single sign-on (SSO) support for NetBox using [Google OAuth2](https://developers.google.com/identity/protocols/oauth2/web-server) as an authentication backend.
|
||||
|
||||
## Google OAuth2 Configuration
|
||||
|
||||
1. Log into [console.cloud.google.com](https://console.cloud.google.com/).
|
||||
2. Create new project for NetBox.
|
||||
3. Under "APIs and Services" click "OAuth consent screen" and enter the required information.
|
||||
4. Under "Credentials," click "Create Credentials" and select "OAuth 2.0 Client ID." Select type "Web application."
|
||||
- "Authorized JavaScript origins" should follow the format `http[s]://<netbox>[:<port>]`
|
||||
- "Authorized redirect URIs" should follow the format `http[s]://<netbox>[:<port>]/oauth/complete/google-oauth2/`
|
||||
5. Copy the "Client ID" and "Client Secret" values somewhere convenient.
|
||||
|
||||
!!! note
|
||||
Google requires the NetBox hostname to use a public top-level-domain (e.g. `.com`, `.net`). The use of IP addresses is not permitted (except `127.0.0.1`).
|
||||
|
||||
For more information, consult [Google's documentation](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites).
|
||||
|
||||
## NetBox Configuration
|
||||
|
||||
### 1. Enter configuration parameters
|
||||
|
||||
Enter the following configuration parameters in `configuration.py`, substituting your own values:
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '{CLIENT_ID}'
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '{CLIENT_SECRET}'
|
||||
```
|
||||
|
||||
### 2. Restart NetBox
|
||||
|
||||
Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Google. Click that link.
|
||||
|
||||

|
||||
|
||||
You should be redirected to Google's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account.
|
||||
|
||||

|
||||
|
||||
If successful, you will be redirected back to the NetBox UI, and will be logged in as the Google user. You can verify this by navigating to your profile (using the button at top right).
|
||||
|
||||
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions.
|
@ -16,7 +16,7 @@ Under the Azure Active Directory dashboard, navigate to **Add > App registration
|
||||
|
||||
Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected.
|
||||
|
||||
Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/entraid-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
|
||||
Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
|
||||
|
||||

|
||||
|
||||
|
@ -106,6 +106,15 @@ By default, NetBox will prevent the creation of duplicate prefixes and IP addres
|
||||
|
||||
---
|
||||
|
||||
## EVENTS_PIPELINE
|
||||
|
||||
|
||||
Default: `['extras.events.process_event_queue',]`
|
||||
|
||||
NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired.
|
||||
|
||||
---
|
||||
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
@ -72,6 +72,9 @@ script_order = (MyCustomScript, AnotherCustomScript)
|
||||
|
||||
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
||||
|
||||
!!! warning
|
||||
These are also defined and used as properties on the base custom script class, so don't use the same names as variables or override them in your custom script.
|
||||
|
||||
### `name`
|
||||
|
||||
This is the human-friendly names of your script. If omitted, the class name will be used.
|
||||
|
@ -62,22 +62,7 @@ $issue-$description
|
||||
|
||||
The description should be just two or three words to imply the focus of the work being performed. For example, bug #1234 to fix a TypeError exception when creating a device might be named `1234-device-typerror`. This ensures that branches are always follow some logical ordering (e.g. when running `git branch -a`) and helps other developers quickly identify the purpose of each.
|
||||
|
||||
### 3. Enable Pre-Commit Hooks
|
||||
|
||||
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
|
||||
|
||||
```no-highlight
|
||||
cd .git/hooks/
|
||||
ln -s ../../scripts/git-hooks/pre-commit
|
||||
```
|
||||
For the pre-commit hooks to work, you will also need to install the [ruff](https://docs.astral.sh/ruff/) linter:
|
||||
|
||||
```no-highlight
|
||||
python -m pip install ruff
|
||||
```
|
||||
...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md)
|
||||
|
||||
### 4. Create a Python Virtual Environment
|
||||
### 3. Create a Python Virtual Environment
|
||||
|
||||
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
|
||||
|
||||
@ -101,7 +86,7 @@ source ~/.venv/netbox/bin/activate
|
||||
|
||||
Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment.
|
||||
|
||||
### 5. Install Required Packages
|
||||
### 4. Install Required Packages
|
||||
|
||||
With the virtual environment activated, install the project's required Python packages using the `pip` module. Required packages are defined in `requirements.txt`. Each line in this file specifies the name and specific version of a required package.
|
||||
|
||||
@ -109,6 +94,26 @@ With the virtual environment activated, install the project's required Python pa
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 5. Install Pre-Commit
|
||||
|
||||
NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate code when commiting new changes. This includes the following operations:
|
||||
|
||||
* Run the `ruff` Python linter
|
||||
* Run Django's internal system check
|
||||
* Check for missing database migrations
|
||||
* Validate any changes to the documentation with `mkdocs`
|
||||
* Validate Typescript & Sass styling with `yarn`
|
||||
* Ensure that any modified static front end assets have been recompiled
|
||||
|
||||
Enable `pre-commit` with the following commands _prior_ to commiting any changes:
|
||||
|
||||
```no-highlight
|
||||
python -m pip install ruff pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
You may also need to set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md).
|
||||
|
||||
### 6. Configure NetBox
|
||||
|
||||
Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters:
|
||||
|
@ -76,4 +76,4 @@ When adding a new dependency, a short description of the package and the URL of
|
||||
|
||||
* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. but never "Netbox" or any other deviation.
|
||||
|
||||
* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size.
|
||||
* There are SVG forms of the NetBox logo for both [light mode](../netbox_logo_light.svg) and [dark mode](../netbox_logo_dark.svg) available. It is preferred to use the SVG logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the desired size.
|
||||
|
@ -5,6 +5,10 @@ img {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.md-content img {
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
margin-bottom: 24px;
|
||||
|
@ -1,4 +1,5 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
{style="height: 100px; margin-bottom: 3em; background: none;"}
|
||||
{style="height: 100px; margin-bottom: 3em; background: none;"}
|
||||
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
|
BIN
docs/media/authentication/google_login_portal.png
Normal file
BIN
docs/media/authentication/google_login_portal.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
docs/media/authentication/netbox_google_login.png
Normal file
BIN
docs/media/authentication/netbox_google_login.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
24
docs/netbox_logo_dark.svg
Normal file
24
docs/netbox_logo_dark.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1299.6 366">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #00f2d4;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-2" d="M337.27,228.59c-12.35,0-22.88,7.8-26.94,18.74h-174.71c-2.9-7.83-9.12-14.04-16.95-16.95V55.67c10.94-4.06,18.74-14.59,18.74-26.94,0-15.87-12.86-28.73-28.73-28.73s-28.73,12.86-28.73,28.73c0,12.35,7.8,22.88,18.74,26.94v174.71c-10.94,4.06-18.74,14.59-18.74,26.94,0,4.28.94,8.33,2.62,11.98l-41.85,41.85c-3.65-1.68-7.7-2.62-11.98-2.62-15.87,0-28.73,12.86-28.73,28.73s12.86,28.73,28.73,28.73,28.73-12.86,28.73-28.73c0-4.28-.94-8.33-2.62-11.98l41.85-41.85c3.65,1.68,7.7,2.62,11.98,2.62,12.35,0,22.88-7.8,26.94-18.74h174.71c4.06,10.94,14.59,18.74,26.94,18.74,15.87,0,28.73-12.86,28.73-28.73s-12.86-28.73-28.73-28.73Z"/>
|
||||
<path class="cls-1" d="M366,28.73c0,15.87-12.86,28.73-28.73,28.73-4.28,0-8.33-.94-11.98-2.62l-41.85,41.85c1.68,3.65,2.62,7.7,2.62,11.98,0,12.35-7.8,22.88-18.74,26.94v174.71c10.94,4.06,18.74,14.59,18.74,26.94,0,15.87-12.86,28.73-28.73,28.73s-28.73-12.86-28.73-28.73c0-12.35,7.8-22.88,18.74-26.94v-174.71c-7.83-2.9-14.04-9.12-16.95-16.95H55.67c-4.06,10.94-14.59,18.74-26.94,18.74-15.87,0-28.73-12.86-28.73-28.73s12.86-28.73,28.73-28.73c12.35,0,22.88,7.8,26.94,18.74h174.71c4.06-10.94,14.59-18.74,26.94-18.74,4.28,0,8.33.94,11.98,2.62l41.85-41.85c-1.68-3.65-2.62-7.7-2.62-11.98,0-15.87,12.86-28.73,28.73-28.73s28.73,12.86,28.73,28.73ZM579.76,136.45c-4.63-4.38-10.18-7.68-16.24-9.66-6.09-2.07-12.48-3.11-18.91-3.08-9.75-.17-19.37,2.17-27.95,6.78-2.68,1.56-5.23,3.35-7.61,5.34v-9.04h-34.53v134.64h34.53v-69.06c-.08-5.7.68-11.38,2.26-16.86,1.26-4.03,3.36-7.74,6.17-10.89,2.41-2.69,5.44-4.74,8.84-5.96,3.71-1.26,7.6-1.89,11.51-1.85,2.99,0,5.97.41,8.84,1.23,2.62.91,5,2.38,6.99,4.32,2.11,2.28,3.78,4.93,4.93,7.81,1.32,4.12,1.95,8.42,1.85,12.74v78.52h34.53v-85.1c.22-7.94-1.18-15.84-4.11-23.23-2.37-6.33-6.16-12.03-11.1-16.65ZM744.41,169.34c2.28,8.16,3.46,16.6,3.49,25.08v13.77h-98.46c.38,2.33,1.22,4.57,2.47,6.58,1.83,3.77,4.51,7.08,7.81,9.66,3.42,2.8,7.32,4.96,11.51,6.37,4.42,1.57,9.08,2.33,13.77,2.26,5.63.24,11.21-1.19,16.03-4.11,5.19-3.31,9.78-7.48,13.57-12.33l3.49-4.11,26.31,20.14-3.29,4.52c-14.18,18.09-34.12,27.34-59.2,27.34-9.78.09-19.49-1.72-28.57-5.34-8.34-3.34-15.84-8.46-21.99-15.01-6.02-6.49-10.7-14.1-13.77-22.4-3.18-8.83-4.78-18.16-4.73-27.54-.02-9.49,1.72-18.9,5.14-27.75,3.36-8.35,8.32-15.96,14.59-22.4,6.24-6.44,13.72-11.54,21.99-15.01,8.74-3.58,18.1-5.4,27.54-5.34,11.92,0,21.99,2.06,30.42,6.37,7.92,3.9,14.87,9.52,20.35,16.44,5.36,6.74,9.28,14.5,11.51,22.82ZM711.31,178.39c-.43-2.36-.98-4.69-1.64-6.99-1.14-3.45-3.04-6.61-5.55-9.25-2.45-2.78-5.56-4.9-9.04-6.17-8.68-3.42-18.36-3.27-26.93.41-3.87,1.69-7.37,4.13-10.28,7.19-2.81,2.83-5.05,6.18-6.58,9.87-.73,1.58-1.28,3.23-1.64,4.93h61.66ZM827.24,230.8c-2.56.57-5.18.84-7.81.82-2.41.12-4.82-.37-6.99-1.44-1.42-1.08-2.55-2.49-3.29-4.11-.93-2.36-1.42-4.87-1.44-7.4-.21-3.29-.41-6.58-.41-9.87v-50.57h33.71v-31.45h-33.71v-34.53h-34.53v34.53h-21.79v31.45h21.79v58.79c-.04,5.15.24,10.3.82,15.42.38,5.56,1.99,10.97,4.73,15.83,3.21,5.18,7.85,9.32,13.36,11.92,5.76,2.88,13.36,4.32,23.43,4.32,3.71-.04,7.42-.31,11.1-.82,4.47-.56,8.79-1.95,12.74-4.11l2.88-1.44v-34.33l-8.43,4.93c-1.93,1.02-4.01,1.72-6.17,2.06ZM997.03,166.46c3.16,8.91,4.76,18.3,4.73,27.75.04,9.32-1.56,18.57-4.73,27.34-3.07,8.3-7.75,15.92-13.77,22.4-6.1,6.56-13.53,11.74-21.79,15.21-8.94,3.62-18.51,5.44-28.16,5.34-9.17-.04-18.22-2.07-26.52-5.96-4.12-1.71-7.93-4.07-11.31-6.99v9.87h-34.53V53.41h34.53v83.04c3.23-2.59,6.75-4.8,10.48-6.58,8.54-4.07,17.88-6.18,27.34-6.17,9.65-.09,19.22,1.72,28.16,5.34,8.18,3.52,15.58,8.62,21.79,15.01,5.91,6.58,10.57,14.17,13.77,22.4ZM963.11,178.8c-1.41-4.39-3.8-8.39-6.99-11.72-3.07-3.26-6.78-5.85-10.89-7.61-9.47-3.57-19.92-3.57-29.39,0-4.12,1.76-7.83,4.35-10.89,7.61-3.12,3.37-5.5,7.37-6.99,11.72-1.71,4.96-2.55,10.17-2.47,15.42-.05,5.24.78,10.45,2.47,15.42,1.54,4.27,3.91,8.18,6.99,11.51,3.01,3.32,6.74,5.92,10.89,7.61,9.42,3.83,19.97,3.83,29.39,0,4.16-1.68,7.88-4.28,10.89-7.61,3.15-3.28,5.54-7.21,6.99-11.51,1.68-4.96,2.52-10.18,2.47-15.42.07-5.24-.77-10.46-2.47-15.42ZM1136.6,244.16c-28.24,27.15-72.89,27.15-101.13,0-13.17-13.29-20.56-31.24-20.55-49.95-.1-28.4,16.95-54.05,43.17-64.95,17.9-7.4,38.01-7.4,55.91,0,26.14,11,43.15,36.59,43.17,64.95,0,18.71-7.38,36.66-20.55,49.95ZM1118.51,178.8c-1.42-4.34-3.73-8.33-6.78-11.72-3.1-3.22-6.8-5.8-10.89-7.61-9.55-3.56-20.05-3.56-29.6,0-4.09,1.81-7.79,4.39-10.89,7.61-3.05,3.39-5.36,7.38-6.78,11.72-1.88,4.92-2.79,10.15-2.67,15.42-.08,5.26.82,10.49,2.67,15.42,1.47,4.25,3.77,8.17,6.78,11.51,3.05,3.28,6.77,5.87,10.89,7.61,9.49,3.84,20.11,3.84,29.6,0,4.13-1.74,7.84-4.33,10.89-7.61,3.01-3.34,5.32-7.26,6.78-11.51,1.75-4.95,2.66-10.16,2.67-15.42,0-5.25-.9-10.47-2.67-15.42ZM1291.58,126.79h-42.34l-26.52,39.47-26.93-39.47h-44.4l48.1,63.1-54.27,71.53h42.96l33.5-47.69,33.71,47.69h44.19l-54.27-71.53,46.25-63.1Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@ -114,6 +114,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
|
||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||
| `queues` | A list of custom background task queues to create |
|
||||
| `events_pipeline` | A list of handlers to add to [`EVENTS_PIPELINE`](./miscellaneous.md#events_pipeline), identified by dotted paths |
|
||||
| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
|
||||
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
|
||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||
|
@ -1,11 +1,24 @@
|
||||
# NetBox v4.1
|
||||
|
||||
## v4.1.4 (FUTURE)
|
||||
## v4.1.5 (FUTURE)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17710](https://github.com/netbox-community/netbox/issues/17710) - Remove cached fields on CableTermination model from GraphQL API
|
||||
* [#17740](https://github.com/netbox-community/netbox/issues/17740) - Ensure support for image attachments with a `.webp` file extension
|
||||
* [#17749](https://github.com/netbox-community/netbox/issues/17749) - Restore missing `devicetypes` and `children` fields for several objects in GraphQL API
|
||||
* [#17754](https://github.com/netbox-community/netbox/issues/17754) - Remove paginator from version history table under plugin view
|
||||
* [#17759](https://github.com/netbox-community/netbox/issues/17759) - Retain `job_timeout` value when scheduling a recurring custom script
|
||||
|
||||
---
|
||||
|
||||
## v4.1.4 (2024-10-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11671](https://github.com/netbox-community/netbox/issues/11671) - Display device's rack position in cable traces
|
||||
* [#15829](https://github.com/netbox-community/netbox/issues/15829) - Rename Microsoft Azure AD SSO backend to Microsoft Entra ID
|
||||
* [#16009](https://github.com/netbox-community/netbox/issues/16009) - Float form & bulk operation buttons within UI
|
||||
* [#17079](https://github.com/netbox-community/netbox/issues/17079) - Introduce additional choices for device airflow direction
|
||||
* [#17216](https://github.com/netbox-community/netbox/issues/17216) - Add EVPN-VPWS L2VPN type
|
||||
* [#17655](https://github.com/netbox-community/netbox/issues/17655) - Limit the display of tagged VLANs within interface tables
|
||||
@ -14,12 +27,16 @@
|
||||
### Bug Fixes
|
||||
|
||||
* [#16024](https://github.com/netbox-community/netbox/issues/16024) - Fix AND/OR filtering in GraphQL API for selection fields
|
||||
* [#17400](https://github.com/netbox-community/netbox/issues/17400) - Fix cable tracing across split paths
|
||||
* [#17562](https://github.com/netbox-community/netbox/issues/17562) - Fix GraphQL API query support for custom field choices
|
||||
* [#17566](https://github.com/netbox-community/netbox/issues/17566) - Fix AttributeError exception resulting from background jobs with no associated object type
|
||||
* [#17614](https://github.com/netbox-community/netbox/issues/17614) - Disallow removal of a master device from its virtual chassis
|
||||
* [#17636](https://github.com/netbox-community/netbox/issues/17636) - Fix filtering of related objects when adding a power port, rear port, or inventory item template to a device type
|
||||
* [#17644](https://github.com/netbox-community/netbox/issues/17644) - Correct sizing of logo & SSO icons on login page
|
||||
* [#17648](https://github.com/netbox-community/netbox/issues/17648) - Fix AttributeError exception when attempting to delete a background job under certain conditions
|
||||
* [#17663](https://github.com/netbox-community/netbox/issues/17663) - Fix extended lookups for choice field filters
|
||||
* [#17671](https://github.com/netbox-community/netbox/issues/17671) - Fix the display of rack types in global search results
|
||||
* [#17713](https://github.com/netbox-community/netbox/issues/17713) - Fix UnboundLocalError exception when attempting to sync data source in parallel
|
||||
|
||||
---
|
||||
|
||||
|
@ -156,6 +156,7 @@ nav:
|
||||
- Administration:
|
||||
- Authentication:
|
||||
- Overview: 'administration/authentication/overview.md'
|
||||
- Google: 'administration/authentication/google.md'
|
||||
- Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md'
|
||||
- Okta: 'administration/authentication/okta.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
|
@ -1,79 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from .serializers_.nested import NestedProviderAccountSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedCircuitSerializer',
|
||||
'NestedCircuitTerminationSerializer',
|
||||
'NestedCircuitTypeSerializer',
|
||||
'NestedProviderNetworkSerializer',
|
||||
'NestedProviderSerializer',
|
||||
'NestedProviderAccountSerializer',
|
||||
]
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Provider networks
|
||||
#
|
||||
|
||||
class NestedProviderNetworkSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ProviderNetwork
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('circuit_count',),
|
||||
)
|
||||
class NestedProviderSerializer(WritableNestedSerializer):
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count']
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('circuit_count',),
|
||||
)
|
||||
class NestedCircuitTypeSerializer(WritableNestedSerializer):
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count']
|
||||
|
||||
|
||||
class NestedCircuitSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'url', 'display_url', 'display', 'cid']
|
||||
|
||||
|
||||
class NestedCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
circuit = NestedCircuitSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'cable', '_occupied']
|
@ -1,47 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import *
|
||||
from netbox.api.fields import ChoiceField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from users.api.serializers import UserSerializer
|
||||
|
||||
__all__ = (
|
||||
'NestedDataFileSerializer',
|
||||
'NestedDataSourceSerializer',
|
||||
'NestedJobSerializer',
|
||||
)
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
class NestedDataSourceSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DataSource
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedDataFileSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DataFile
|
||||
fields = ['id', 'url', 'display_url', 'display', 'path']
|
||||
|
||||
|
||||
class NestedJobSerializer(serializers.ModelSerializer):
|
||||
status = ChoiceField(choices=JobStatusChoices)
|
||||
user = UserSerializer(
|
||||
nested=True,
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['url', 'display_url', 'created', 'completed', 'user', 'status']
|
@ -1,389 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim import models
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from .serializers_.nested import (
|
||||
NestedDeviceBaySerializer, NestedDeviceSerializer, NestedInterfaceSerializer, NestedInterfaceTemplateSerializer,
|
||||
NestedLocationSerializer, NestedModuleBaySerializer, NestedRegionSerializer, NestedSiteGroupSerializer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'NestedCableSerializer',
|
||||
'NestedConsolePortSerializer',
|
||||
'NestedConsolePortTemplateSerializer',
|
||||
'NestedConsoleServerPortSerializer',
|
||||
'NestedConsoleServerPortTemplateSerializer',
|
||||
'NestedDeviceBaySerializer',
|
||||
'NestedDeviceBayTemplateSerializer',
|
||||
'NestedDeviceRoleSerializer',
|
||||
'NestedDeviceSerializer',
|
||||
'NestedDeviceTypeSerializer',
|
||||
'NestedFrontPortSerializer',
|
||||
'NestedFrontPortTemplateSerializer',
|
||||
'NestedInterfaceSerializer',
|
||||
'NestedInterfaceTemplateSerializer',
|
||||
'NestedInventoryItemSerializer',
|
||||
'NestedInventoryItemRoleSerializer',
|
||||
'NestedInventoryItemTemplateSerializer',
|
||||
'NestedManufacturerSerializer',
|
||||
'NestedModuleBaySerializer',
|
||||
'NestedModuleBayTemplateSerializer',
|
||||
'NestedModuleSerializer',
|
||||
'NestedModuleTypeSerializer',
|
||||
'NestedPlatformSerializer',
|
||||
'NestedPowerFeedSerializer',
|
||||
'NestedPowerOutletSerializer',
|
||||
'NestedPowerOutletTemplateSerializer',
|
||||
'NestedPowerPanelSerializer',
|
||||
'NestedPowerPortSerializer',
|
||||
'NestedPowerPortTemplateSerializer',
|
||||
'NestedLocationSerializer',
|
||||
'NestedRackReservationSerializer',
|
||||
'NestedRackRoleSerializer',
|
||||
'NestedRackSerializer',
|
||||
'NestedRearPortSerializer',
|
||||
'NestedRearPortTemplateSerializer',
|
||||
'NestedRegionSerializer',
|
||||
'NestedSiteSerializer',
|
||||
'NestedSiteGroupSerializer',
|
||||
'NestedVirtualChassisSerializer',
|
||||
'NestedVirtualDeviceContextSerializer',
|
||||
]
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Regions/sites
|
||||
#
|
||||
|
||||
class NestedSiteSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Site
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('rack_count',),
|
||||
)
|
||||
class NestedRackRoleSerializer(WritableNestedSerializer):
|
||||
rack_count = RelatedObjectCountField('racks')
|
||||
|
||||
class Meta:
|
||||
model = models.RackRole
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count']
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('device_count',),
|
||||
)
|
||||
class NestedRackSerializer(WritableNestedSerializer):
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
|
||||
class Meta:
|
||||
model = models.Rack
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'device_count']
|
||||
|
||||
|
||||
class NestedRackReservationSerializer(WritableNestedSerializer):
|
||||
user = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.RackReservation
|
||||
fields = ['id', 'url', 'display_url', 'display', 'user', 'units']
|
||||
|
||||
def get_user(self, obj):
|
||||
return obj.user.username
|
||||
|
||||
|
||||
#
|
||||
# Device/module types
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('devicetype_count',),
|
||||
)
|
||||
class NestedManufacturerSerializer(WritableNestedSerializer):
|
||||
devicetype_count = RelatedObjectCountField('device_types')
|
||||
|
||||
class Meta:
|
||||
model = models.Manufacturer
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'devicetype_count']
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('device_count',),
|
||||
)
|
||||
class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
||||
manufacturer = NestedManufacturerSerializer(read_only=True)
|
||||
device_count = RelatedObjectCountField('instances')
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceType
|
||||
fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
|
||||
|
||||
|
||||
class NestedModuleTypeSerializer(WritableNestedSerializer):
|
||||
manufacturer = NestedManufacturerSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleType
|
||||
fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model']
|
||||
|
||||
|
||||
#
|
||||
# Component templates
|
||||
#
|
||||
|
||||
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePortTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPortTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPortTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutletTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.RearPortTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.FrontPortTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedModuleBayTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleBayTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceBayTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItemTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', '_depth']
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('device_count', 'virtualmachine_count'),
|
||||
)
|
||||
class NestedDeviceRoleSerializer(WritableNestedSerializer):
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
|
||||
class Meta:
|
||||
model = models.DeviceRole
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('device_count', 'virtualmachine_count'),
|
||||
)
|
||||
class NestedPlatformSerializer(WritableNestedSerializer):
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
|
||||
class Meta:
|
||||
model = models.Platform
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ModuleBay
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedModuleSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
module_bay = ModuleNestedModuleBaySerializer(read_only=True)
|
||||
module_type = NestedModuleTypeSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Module
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type']
|
||||
|
||||
|
||||
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedConsolePortSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedPowerOutletSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedPowerPortSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedRearPortSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.RearPort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedFrontPortSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.FrontPort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedInventoryItemSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItem
|
||||
fields = ['id', 'url', 'display_url', 'display', 'device', 'name', '_depth']
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('inventoryitem_count',),
|
||||
)
|
||||
class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
|
||||
inventoryitem_count = RelatedObjectCountField('inventory_items')
|
||||
|
||||
class Meta:
|
||||
model = models.InventoryItemRole
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'inventoryitem_count']
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
#
|
||||
|
||||
class NestedCableSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Cable
|
||||
fields = ['id', 'url', 'display_url', 'display', 'label']
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('member_count',),
|
||||
)
|
||||
class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
||||
master = NestedDeviceSerializer()
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualChassis
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'master', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels/feeds
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('powerfeed_count',),
|
||||
)
|
||||
class NestedPowerPanelSerializer(WritableNestedSerializer):
|
||||
powerfeed_count = RelatedObjectCountField('powerfeeds')
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPanel
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'powerfeed_count']
|
||||
|
||||
|
||||
class NestedPowerFeedSerializer(WritableNestedSerializer):
|
||||
_occupied = serializers.BooleanField(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualDeviceContext
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'identifier', 'device']
|
@ -21,12 +21,13 @@ __all__ = (
|
||||
class RegionSerializer(NestedGroupModelSerializer):
|
||||
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
|
||||
site_count = serializers.IntegerField(read_only=True, default=0)
|
||||
prefix_count = RelatedObjectCountField('_prefixes')
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'site_count', '_depth',
|
||||
'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
@ -34,12 +35,13 @@ class RegionSerializer(NestedGroupModelSerializer):
|
||||
class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
|
||||
site_count = serializers.IntegerField(read_only=True, default=0)
|
||||
prefix_count = RelatedObjectCountField('_prefixes')
|
||||
|
||||
class Meta:
|
||||
model = SiteGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'site_count', '_depth',
|
||||
'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
|
||||
|
||||
@ -61,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
||||
# Related object counts
|
||||
circuit_count = RelatedObjectCountField('circuit_terminations')
|
||||
device_count = RelatedObjectCountField('devices')
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
prefix_count = RelatedObjectCountField('_prefixes')
|
||||
rack_count = RelatedObjectCountField('racks')
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
virtualmachine_count = RelatedObjectCountField('virtual_machines')
|
||||
@ -84,11 +86,13 @@ class LocationSerializer(NestedGroupModelSerializer):
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
rack_count = serializers.IntegerField(read_only=True, default=0)
|
||||
device_count = serializers.IntegerField(read_only=True, default=0)
|
||||
prefix_count = RelatedObjectCountField('_prefixes')
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
|
||||
'prefix_count', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
|
||||
|
@ -112,7 +112,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
|
||||
|
||||
@strawberry_django.type(
|
||||
models.CableTermination,
|
||||
exclude=('termination_type', 'termination_id'),
|
||||
exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'),
|
||||
filters=CableTerminationFilter
|
||||
)
|
||||
class CableTerminationType(NetBoxObjectType):
|
||||
@ -243,6 +243,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo
|
||||
consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
|
||||
poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
|
||||
frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
|
||||
devicebays: List[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]]
|
||||
modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
|
||||
services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
|
||||
inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
@ -662,6 +662,14 @@ class CablePath(models.Model):
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
rear_port_position__in=position_stack.pop()
|
||||
)
|
||||
# If all rear ports have a single position, we can just get the front ports
|
||||
elif all([rp.positions == 1 for rp in remote_terminations]):
|
||||
front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations])
|
||||
|
||||
if len(front_ports) != len(remote_terminations):
|
||||
# Some rear ports does not have a front port
|
||||
is_split = True
|
||||
break
|
||||
else:
|
||||
# No position indicated: path has split, so we stop at the RearPorts
|
||||
is_split = True
|
||||
|
@ -966,6 +966,13 @@ class Device(
|
||||
'vc_position': _("A device assigned to a virtual chassis must have its position defined.")
|
||||
})
|
||||
|
||||
if hasattr(self, 'vc_master_for') and self.vc_master_for and self.vc_master_for != self.virtual_chassis:
|
||||
raise ValidationError({
|
||||
'virtual_chassis': _('Device cannot be removed from virtual chassis {virtual_chassis} because it is currently designated as its master.').format(
|
||||
virtual_chassis=self.vc_master_for
|
||||
)
|
||||
})
|
||||
|
||||
def _instantiate_components(self, queryset, bulk_create=True):
|
||||
"""
|
||||
Instantiate components for the device from the specified component templates.
|
||||
|
@ -28,6 +28,12 @@ class Region(ContactsMixin, NestedGroupModel):
|
||||
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
|
||||
also considered to be members of its parent and ancestor region(s).
|
||||
"""
|
||||
prefixes = GenericRelation(
|
||||
to='ipam.Prefix',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='region'
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
@ -78,6 +84,12 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
|
||||
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
|
||||
nested recursively to form a hierarchy.
|
||||
"""
|
||||
prefixes = GenericRelation(
|
||||
to='ipam.Prefix',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site_group'
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
@ -214,6 +226,12 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
prefixes = GenericRelation(
|
||||
to='ipam.Prefix',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site'
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
@ -273,6 +291,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
prefixes = GenericRelation(
|
||||
to='ipam.Prefix',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='location'
|
||||
)
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
|
@ -2060,6 +2060,49 @@ class CablePathTestCase(TestCase):
|
||||
# Test SVG generation
|
||||
CableTraceSVG(interface1).render()
|
||||
|
||||
def test_222_single_path_via_multiple_singleposition_rear_ports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
|
||||
[FP2] [RP2]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport2]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1, rearport2],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable2.save()
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, (frontport1, frontport2), (rearport1, rearport2), cable2, interface2),
|
||||
is_complete=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(interface2, cable2, (rearport1, rearport2), (frontport1, frontport2), cable1, interface1),
|
||||
is_complete=True
|
||||
)
|
||||
|
||||
# Test SVG generation both directions
|
||||
CableTraceSVG(interface1).render()
|
||||
CableTraceSVG(interface2).render()
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
|
@ -1,135 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras import models
|
||||
from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedBookmarkSerializer',
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedConfigTemplateSerializer',
|
||||
'NestedCustomFieldChoiceSetSerializer',
|
||||
'NestedCustomFieldSerializer',
|
||||
'NestedCustomLinkSerializer',
|
||||
'NestedEventRuleSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedImageAttachmentSerializer',
|
||||
'NestedJournalEntrySerializer',
|
||||
'NestedSavedFilterSerializer',
|
||||
'NestedScriptSerializer',
|
||||
'NestedTagSerializer', # Defined in netbox.api.serializers
|
||||
'NestedWebhookSerializer',
|
||||
]
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
class NestedEventRuleSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.EventRule
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedWebhookSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Webhook
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedCustomFieldSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomField
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomFieldChoiceSet
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'choices_count']
|
||||
|
||||
|
||||
class NestedCustomLinkSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.CustomLink
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedConfigContextSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ConfigContext
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedConfigTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ConfigTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedExportTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ExportTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedSavedFilterSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.SavedFilter
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
|
||||
|
||||
|
||||
class NestedBookmarkSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Bookmark
|
||||
fields = ['id', 'url', 'display', 'object_id', 'object_type']
|
||||
|
||||
|
||||
class NestedImageAttachmentSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ImageAttachment
|
||||
fields = ['id', 'url', 'display', 'name', 'image']
|
||||
|
||||
|
||||
class NestedJournalEntrySerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.JournalEntry
|
||||
fields = ['id', 'url', 'display_url', 'display', 'created']
|
||||
|
||||
|
||||
class NestedScriptSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:script-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
display_url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras:script',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
name = serializers.CharField(read_only=True)
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Script
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
@ -49,7 +49,6 @@ class ScriptJob(JobRunner):
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if script.failed:
|
||||
logger.warning("Script failed")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if type(e) is AbortScript:
|
||||
|
@ -33,7 +33,7 @@ def image_upload(instance, filename):
|
||||
|
||||
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
|
||||
extension = filename.rsplit('.')[-1].lower()
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']:
|
||||
filename = '.'.join([instance.name, extension])
|
||||
elif instance.name:
|
||||
filename = instance.name
|
||||
|
@ -1,204 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from drf_spectacular.utils import extend_schema_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from ipam import models
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from .field_serializers import IPAddressField
|
||||
from .serializers_.nested import NestedIPAddressSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedAggregateSerializer',
|
||||
'NestedASNSerializer',
|
||||
'NestedASNRangeSerializer',
|
||||
'NestedFHRPGroupSerializer',
|
||||
'NestedFHRPGroupAssignmentSerializer',
|
||||
'NestedIPAddressSerializer',
|
||||
'NestedIPRangeSerializer',
|
||||
'NestedPrefixSerializer',
|
||||
'NestedRIRSerializer',
|
||||
'NestedRoleSerializer',
|
||||
'NestedRouteTargetSerializer',
|
||||
'NestedServiceSerializer',
|
||||
'NestedServiceTemplateSerializer',
|
||||
'NestedVLANGroupSerializer',
|
||||
'NestedVLANSerializer',
|
||||
'NestedVRFSerializer',
|
||||
]
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# ASN ranges
|
||||
#
|
||||
|
||||
class NestedASNRangeSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ASNRange
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
#
|
||||
# ASNs
|
||||
#
|
||||
|
||||
class NestedASNSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ASN
|
||||
fields = ['id', 'url', 'display_url', 'display', 'asn']
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('prefix_count',),
|
||||
)
|
||||
class NestedVRFSerializer(WritableNestedSerializer):
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
|
||||
class Meta:
|
||||
model = models.VRF
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'rd', 'prefix_count']
|
||||
|
||||
|
||||
#
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class NestedRouteTargetSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.RouteTarget
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
#
|
||||
# RIRs/aggregates
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('aggregate_count',),
|
||||
)
|
||||
class NestedRIRSerializer(WritableNestedSerializer):
|
||||
aggregate_count = RelatedObjectCountField('aggregates')
|
||||
|
||||
class Meta:
|
||||
model = models.RIR
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'aggregate_count']
|
||||
|
||||
|
||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'family', 'prefix']
|
||||
|
||||
|
||||
#
|
||||
# FHRP groups
|
||||
#
|
||||
|
||||
class NestedFHRPGroupSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroup
|
||||
fields = ['id', 'url', 'display_url', 'display', 'protocol', 'group_id']
|
||||
|
||||
|
||||
class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
|
||||
group = NestedFHRPGroupSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.FHRPGroupAssignment
|
||||
fields = ['id', 'url', 'display_url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('prefix_count', 'vlan_count'),
|
||||
)
|
||||
class NestedRoleSerializer(WritableNestedSerializer):
|
||||
prefix_count = RelatedObjectCountField('prefixes')
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
|
||||
class Meta:
|
||||
model = models.Role
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count']
|
||||
|
||||
|
||||
@extend_schema_serializer(
|
||||
exclude_fields=('vlan_count',),
|
||||
)
|
||||
class NestedVLANGroupSerializer(WritableNestedSerializer):
|
||||
vlan_count = RelatedObjectCountField('vlans')
|
||||
|
||||
class Meta:
|
||||
model = models.VLANGroup
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'vlan_count']
|
||||
|
||||
|
||||
class NestedVLANSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.VLAN
|
||||
fields = ['id', 'url', 'display_url', 'display', 'vid', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
fields = ['id', 'url', 'display_url', 'display', 'family', 'prefix', '_depth']
|
||||
|
||||
|
||||
#
|
||||
# IP ranges
|
||||
#
|
||||
|
||||
class NestedIPRangeSerializer(WritableNestedSerializer):
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
start_address = IPAddressField()
|
||||
end_address = IPAddressField()
|
||||
|
||||
class Meta:
|
||||
model = models.IPRange
|
||||
fields = ['id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address']
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class NestedServiceTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceTemplate
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'protocol', 'ports']
|
||||
|
||||
|
||||
class NestedServiceSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Service
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'protocol', 'ports']
|
@ -2,9 +2,8 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.serializers_.sites import SiteSerializer
|
||||
from ipam.choices import *
|
||||
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
|
||||
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
@ -45,8 +44,17 @@ class AggregateSerializer(NetBoxModelSerializer):
|
||||
|
||||
class PrefixSerializer(NetBoxModelSerializer):
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
site = SiteSerializer(nested=True, required=False, allow_null=True)
|
||||
vrf = VRFSerializer(nested=True, required=False, allow_null=True)
|
||||
scope_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(
|
||||
model__in=PREFIX_SCOPE_TYPES
|
||||
),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
default=None
|
||||
)
|
||||
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
||||
scope = serializers.SerializerMethodField(read_only=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
vlan = VLANSerializer(nested=True, required=False, allow_null=True)
|
||||
status = ChoiceField(choices=PrefixStatusChoices, required=False)
|
||||
@ -58,12 +66,20 @@ class PrefixSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status',
|
||||
'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'children', '_depth',
|
||||
'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope',
|
||||
'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'children', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_scope(self, obj):
|
||||
if obj.scope_id is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.scope)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.scope, nested=True, context=context).data
|
||||
|
||||
|
||||
class PrefixLengthSerializer(serializers.Serializer):
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class IPAMConfig(AppConfig):
|
||||
name = "ipam"
|
||||
@ -8,6 +10,16 @@ class IPAMConfig(AppConfig):
|
||||
def ready(self):
|
||||
from netbox.models.features import register_models
|
||||
from . import signals, search # noqa: F401
|
||||
from .models import Prefix
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(Prefix, '_site', {
|
||||
'_region': 'region',
|
||||
'_sitegroup': 'group',
|
||||
})
|
||||
denormalized.register(Prefix, '_location', {
|
||||
'_site': 'site',
|
||||
})
|
||||
|
@ -23,6 +23,11 @@ VRF_RD_MAX_LENGTH = 21
|
||||
PREFIX_LENGTH_MIN = 1
|
||||
PREFIX_LENGTH_MAX = 127 # IPv6
|
||||
|
||||
# models values for ContentTypes which may be Prefix scope types
|
||||
PREFIX_SCOPE_TYPES = (
|
||||
'region', 'sitegroup', 'site', 'location',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# IPAddresses
|
||||
|
@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
@ -334,42 +334,57 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
scope_type = ContentTypeFilter()
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
field_name='_region',
|
||||
lookup_expr='in',
|
||||
label=_('Region (ID)'),
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region',
|
||||
field_name='_region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Region (slug)'),
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='site__group',
|
||||
field_name='_sitegroup',
|
||||
lookup_expr='in',
|
||||
label=_('Site group (ID)'),
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='site__group',
|
||||
field_name='_sitegroup',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
field_name='_site',
|
||||
label=_('Site (ID)'),
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='site__slug',
|
||||
field_name='_site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Site (slug)'),
|
||||
)
|
||||
location_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='_location',
|
||||
lookup_expr='in',
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='_location',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VLAN.objects.all(),
|
||||
label=_('VLAN (ID)'),
|
||||
@ -395,7 +410,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ('id', 'is_pool', 'mark_utilized', 'description')
|
||||
fields = ('id', 'scope_id', 'is_pool', 'mark_utilized', 'description')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@ -1,22 +1,23 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
NumericRangeArrayField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
|
||||
__all__ = (
|
||||
'AggregateBulkEditForm',
|
||||
@ -205,24 +206,18 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
label=_('Site group'),
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
scope_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
label=_('Scope type')
|
||||
)
|
||||
scope = DynamicModelChoiceField(
|
||||
label=_('Scope'),
|
||||
queryset=Site.objects.none(), # Initial queryset
|
||||
required=False,
|
||||
disabled=True,
|
||||
selector=True
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
@ -283,14 +278,28 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = Prefix
|
||||
fieldsets = (
|
||||
FieldSet('tenant', 'status', 'role', 'description'),
|
||||
FieldSet('region', 'site_group', 'site', name=_('Site')),
|
||||
FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
|
||||
FieldSet('scope_type', 'scope', name=_('Scope')),
|
||||
FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments',
|
||||
'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if scope_type_id := get_field_value(self, 'scope_type'):
|
||||
try:
|
||||
scope_type = ContentType.objects.get(pk=scope_type_id)
|
||||
model = scope_type.model_class()
|
||||
self.fields['scope'].queryset = model.objects.all()
|
||||
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
|
||||
self.fields['scope'].disabled = False
|
||||
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
@ -431,62 +440,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
scope_type = ContentTypeChoiceField(
|
||||
label=_('Scope type'),
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False
|
||||
)
|
||||
scope_id = forms.IntegerField(
|
||||
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
label=_('Scope type')
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
scope = DynamicModelChoiceField(
|
||||
label=_('Scope'),
|
||||
queryset=Site.objects.none(), # Initial queryset
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$sitegroup',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
label=_('Location'),
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
label=_('Rack'),
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
clustergroup = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster group')
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$clustergroup',
|
||||
}
|
||||
disabled=True,
|
||||
selector=True
|
||||
)
|
||||
vid_ranges = NumericRangeArrayField(
|
||||
label=_('VLAN ID ranges'),
|
||||
@ -496,24 +460,23 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = VLANGroup
|
||||
fieldsets = (
|
||||
FieldSet('site', 'vid_ranges', 'description'),
|
||||
FieldSet(
|
||||
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
|
||||
),
|
||||
FieldSet('scope_type', 'scope', name=_('Scope')),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
nullable_fields = ('description', 'scope')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Assign scope based on scope_type
|
||||
if self.cleaned_data.get('scope_type'):
|
||||
scope_field = self.cleaned_data['scope_type'].model
|
||||
if scope_obj := self.cleaned_data.get(scope_field):
|
||||
self.cleaned_data['scope_id'] = scope_obj.pk
|
||||
self.changed_data.append('scope_id')
|
||||
else:
|
||||
self.cleaned_data.pop('scope_type')
|
||||
self.changed_data.remove('scope_type')
|
||||
if scope_type_id := get_field_value(self, 'scope_type'):
|
||||
try:
|
||||
scope_type = ContentType.objects.get(pk=scope_type_id)
|
||||
model = scope_type.model_class()
|
||||
self.fields['scope'].queryset = model.objects.all()
|
||||
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
|
||||
self.fields['scope'].disabled = False
|
||||
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
@ -169,12 +169,10 @@ class PrefixImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
site = CSVModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
scope_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned site')
|
||||
label=_('Scope type (app & model)')
|
||||
)
|
||||
vlan_group = CSVModelChoiceField(
|
||||
label=_('VLAN group'),
|
||||
@ -206,9 +204,12 @@ class PrefixImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = (
|
||||
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized',
|
||||
'description', 'comments', 'tags',
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
|
||||
'mark_utilized', 'description', 'comments', 'tags',
|
||||
)
|
||||
labels = {
|
||||
'scope_id': 'Scope ID',
|
||||
}
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
@ -172,7 +172,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
),
|
||||
FieldSet('vlan_id', name=_('VLAN Assignment')),
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
@ -226,12 +226,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
required=False,
|
||||
|
@ -203,12 +203,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
scope_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES),
|
||||
widget=HTMXSelect(),
|
||||
required=False,
|
||||
selector=True,
|
||||
null_option='None'
|
||||
label=_('Scope type')
|
||||
)
|
||||
scope = DynamicModelChoiceField(
|
||||
label=_('Scope'),
|
||||
queryset=Site.objects.none(), # Initial queryset
|
||||
required=False,
|
||||
disabled=True,
|
||||
selector=True
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@ -230,17 +236,48 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
FieldSet(
|
||||
'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix')
|
||||
),
|
||||
FieldSet('site', 'vlan', name=_('Site/VLAN Assignment')),
|
||||
FieldSet('scope_type', 'scope', name=_('Scope')),
|
||||
FieldSet('vlan', name=_('VLAN Assignment')),
|
||||
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = [
|
||||
'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant',
|
||||
'description', 'comments', 'tags',
|
||||
'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', 'tenant_group',
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {})
|
||||
|
||||
if instance is not None and instance.scope:
|
||||
initial['scope'] = instance.scope
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if scope_type_id := get_field_value(self, 'scope_type'):
|
||||
try:
|
||||
scope_type = ContentType.objects.get(pk=scope_type_id)
|
||||
model = scope_type.model_class()
|
||||
self.fields['scope'].queryset = model.objects.all()
|
||||
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
|
||||
self.fields['scope'].disabled = False
|
||||
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if self.instance and scope_type_id != self.instance.scope_type_id:
|
||||
self.initial['scope'] = None
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Assign the selected scope (if any)
|
||||
self.instance.scope = self.cleaned_data.get('scope')
|
||||
|
||||
|
||||
class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
|
@ -153,17 +153,25 @@ class IPRangeType(NetBoxObjectType):
|
||||
|
||||
@strawberry_django.type(
|
||||
models.Prefix,
|
||||
fields='__all__',
|
||||
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'),
|
||||
filters=PrefixFilter
|
||||
)
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
prefix: str
|
||||
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
|
||||
@strawberry_django.field
|
||||
def scope(self) -> Annotated[Union[
|
||||
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
|
||||
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
|
||||
], strawberry.union("PrefixScopeType")] | None:
|
||||
return self.scope
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
models.RIR,
|
||||
|
51
netbox/ipam/migrations/0071_prefix_scope.py
Normal file
51
netbox/ipam/migrations/0071_prefix_scope.py
Normal file
@ -0,0 +1,51 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_site_assignments(apps, schema_editor):
|
||||
"""
|
||||
Copy site ForeignKey values to the scope GFK.
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Prefix = apps.get_model('ipam', 'Prefix')
|
||||
Site = apps.get_model('dcim', 'Site')
|
||||
|
||||
Prefix.objects.filter(site__isnull=False).update(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=models.F('site_id')
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('ipam', '0070_vlangroup_vlan_id_ranges'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add the `scope` GenericForeignKey
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='scope_id',
|
||||
field=models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='scope_type',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='+',
|
||||
to='contenttypes.contenttype'
|
||||
),
|
||||
),
|
||||
|
||||
# Copy over existing site assignments
|
||||
migrations.RunPython(
|
||||
code=copy_site_assignments,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
61
netbox/ipam/migrations/0072_prefix_cached_relations.py
Normal file
61
netbox/ipam/migrations/0072_prefix_cached_relations.py
Normal file
@ -0,0 +1,61 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_denormalized_fields(apps, schema_editor):
|
||||
"""
|
||||
Copy site ForeignKey values to the scope GFK.
|
||||
"""
|
||||
Prefix = apps.get_model('ipam', 'Prefix')
|
||||
|
||||
prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site')
|
||||
for prefix in prefixes:
|
||||
prefix._region_id = prefix.site.region_id
|
||||
prefix._sitegroup_id = prefix.site.group_id
|
||||
prefix._site_id = prefix.site_id
|
||||
# Note: Location cannot be set prior to migration
|
||||
|
||||
Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0193_poweroutlet_color'),
|
||||
('ipam', '0071_prefix_scope'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='_location',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='_region',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='_site',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='prefix',
|
||||
name='_sitegroup',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'),
|
||||
),
|
||||
|
||||
# Populate denormalized FK values
|
||||
migrations.RunPython(
|
||||
code=populate_denormalized_fields,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
|
||||
# Delete the site ForeignKey
|
||||
migrations.RemoveField(
|
||||
model_name='prefix',
|
||||
name='site',
|
||||
),
|
||||
]
|
@ -1,4 +1,5 @@
|
||||
import netaddr
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
@ -199,21 +200,30 @@ class Role(OrganizationalModel):
|
||||
|
||||
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
||||
assigned to a VLAN where appropriate.
|
||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain
|
||||
areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role.
|
||||
A Prefix can also be assigned to a VLAN where appropriate.
|
||||
"""
|
||||
prefix = IPNetworkField(
|
||||
verbose_name=_('prefix'),
|
||||
help_text=_('IPv4 or IPv6 network with mask')
|
||||
)
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
scope_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='prefixes',
|
||||
limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES),
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
scope_id = models.PositiveBigIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
scope = GenericForeignKey(
|
||||
ct_field='scope_type',
|
||||
fk_field='scope_id'
|
||||
)
|
||||
vrf = models.ForeignKey(
|
||||
to='ipam.VRF',
|
||||
on_delete=models.PROTECT,
|
||||
@ -262,6 +272,36 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
help_text=_("Treat as fully utilized")
|
||||
)
|
||||
|
||||
# Cached associations to enable efficient filtering
|
||||
_location = models.ForeignKey(
|
||||
to='dcim.Location',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='_prefixes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='_prefixes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_region = models.ForeignKey(
|
||||
to='dcim.Region',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='_prefixes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_sitegroup = models.ForeignKey(
|
||||
to='dcim.SiteGroup',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='_prefixes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
# Cached depth & child counts
|
||||
_depth = models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
@ -275,7 +315,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
objects = PrefixQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
|
||||
'scope_type', 'scope_id', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -323,8 +363,30 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
# Clear host bits from prefix
|
||||
self.prefix = self.prefix.cidr
|
||||
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
self.cache_related_objects()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def cache_related_objects(self):
|
||||
self._region = self._sitegroup = self._site = self._location = None
|
||||
if self.scope_type:
|
||||
scope_type = self.scope_type.model_class()
|
||||
if scope_type == apps.get_model('dcim', 'region'):
|
||||
self._region = self.scope
|
||||
elif scope_type == apps.get_model('dcim', 'sitegroup'):
|
||||
self._sitegroup = self.scope
|
||||
elif scope_type == apps.get_model('dcim', 'site'):
|
||||
self._region = self.scope.region
|
||||
self._sitegroup = self.scope.group
|
||||
self._site = self.scope
|
||||
elif scope_type == apps.get_model('dcim', 'location'):
|
||||
self._region = self.scope.site.region
|
||||
self._sitegroup = self.scope.site.group
|
||||
self._site = self.scope.site
|
||||
self._location = self.scope
|
||||
cache_related_objects.alters_data = True
|
||||
|
||||
@property
|
||||
def family(self):
|
||||
return self.prefix.version if self.prefix else None
|
||||
|
@ -241,8 +241,11 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
template_code=VRF_LINK,
|
||||
verbose_name=_('VRF')
|
||||
)
|
||||
site = tables.Column(
|
||||
verbose_name=_('Site'),
|
||||
scope_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Scope Type'),
|
||||
)
|
||||
scope = tables.Column(
|
||||
verbose_name=_('Scope'),
|
||||
linkify=True
|
||||
)
|
||||
vlan_group = tables.Column(
|
||||
@ -285,11 +288,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
|
||||
'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
|
||||
'created', 'last_updated',
|
||||
'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role',
|
||||
'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not record.pk else '',
|
||||
|
@ -656,14 +656,14 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
prefixes = (
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
|
||||
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='10.0.0.0/24', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
|
||||
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
|
||||
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='2001:db8::/64', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
|
||||
Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
|
||||
Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
|
||||
Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
|
||||
Prefix(prefix='10.0.0.0/16'),
|
||||
Prefix(prefix='2001:db8::/32'),
|
||||
)
|
||||
|
@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
@ -409,9 +410,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Role.objects.bulk_create(roles)
|
||||
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
|
||||
Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
|
||||
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
|
||||
Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
|
||||
Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
|
||||
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
|
||||
@ -419,7 +420,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.form_data = {
|
||||
'prefix': IPNetwork('192.0.2.0/24'),
|
||||
'site': sites[1].pk,
|
||||
'scope_type': ContentType.objects.get_for_model(Site).pk,
|
||||
'scope': sites[1].pk,
|
||||
'vrf': vrfs[1].pk,
|
||||
'tenant': None,
|
||||
'vlan': None,
|
||||
@ -430,11 +432,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
site = sites[0].pk
|
||||
cls.csv_data = (
|
||||
"vrf,prefix,status",
|
||||
"VRF 1,10.4.0.0/16,active",
|
||||
"VRF 1,10.5.0.0/16,active",
|
||||
"VRF 1,10.6.0.0/16,active",
|
||||
"vrf,prefix,status,scope_type,scope_id",
|
||||
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
|
||||
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
|
||||
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@ -445,7 +448,6 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'site': sites[1].pk,
|
||||
'vrf': vrfs[1].pk,
|
||||
'tenant': None,
|
||||
'status': PrefixStatusChoices.STATUS_RESERVED,
|
||||
@ -501,11 +503,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
"""
|
||||
Custom import test for YAML-based imports (versus CSV)
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
site = Site.objects.get(name='Site 1')
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.1.1.0/24
|
||||
status: active
|
||||
vlan: 101
|
||||
site: Site 1
|
||||
scope_type: dcim.site
|
||||
scope_id: {site.pk}
|
||||
"""
|
||||
# Note, a site is not tied to the VLAN to verify the fix for #12622
|
||||
VLAN.objects.create(vid=101, name='VLAN101')
|
||||
@ -523,19 +527,21 @@ site: Site 1
|
||||
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
|
||||
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||
self.assertEqual(prefix.vlan.vid, 101)
|
||||
self.assertEqual(prefix.site.name, "Site 1")
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_group(self):
|
||||
"""
|
||||
This test covers a unique import edge case where VLAN group is specified during the import.
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
site = Site.objects.get(name='Site 1')
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.1.2.0/24
|
||||
status: active
|
||||
vlan: 102
|
||||
site: Site 1
|
||||
scope_type: dcim.site
|
||||
scope_id: {site.pk}
|
||||
vlan_group: Group 1
|
||||
vlan: 102
|
||||
"""
|
||||
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
|
||||
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
|
||||
@ -553,7 +559,7 @@ vlan_group: Group 1
|
||||
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
|
||||
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||
self.assertEqual(prefix.vlan.vid, 102)
|
||||
self.assertEqual(prefix.site.name, "Site 1")
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
@ -352,7 +352,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
def get_children(self, request, parent):
|
||||
return Prefix.objects.restrict(request.user, 'view').filter(
|
||||
prefix__net_contained_or_equal=str(parent.prefix)
|
||||
).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan')
|
||||
).prefetch_related('scope', 'role', 'tenant', 'tenant__group', 'vlan')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
# Determine whether to show assigned prefixes, available prefixes, or both
|
||||
@ -492,7 +492,7 @@ class PrefixView(generic.ObjectView):
|
||||
).filter(
|
||||
prefix__net_contains=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
'site', 'role', 'tenant', 'vlan',
|
||||
'scope', 'role', 'tenant', 'vlan',
|
||||
)
|
||||
parent_prefix_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
@ -506,7 +506,7 @@ class PrefixView(generic.ObjectView):
|
||||
).exclude(
|
||||
pk=instance.pk
|
||||
).prefetch_related(
|
||||
'site', 'role', 'tenant', 'vlan',
|
||||
'scope', 'role', 'tenant', 'vlan',
|
||||
)
|
||||
duplicate_prefix_table = tables.PrefixTable(
|
||||
list(duplicate_prefixes),
|
||||
@ -538,7 +538,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group'
|
||||
'scope', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group'
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
|
@ -20,10 +20,10 @@ AUTH_BACKEND_ATTRS = {
|
||||
'amazon': ('Amazon AWS', 'aws'),
|
||||
'apple': ('Apple', 'apple'),
|
||||
'auth0': ('Auth0', None),
|
||||
'entraid-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'bitbucket': ('BitBucket', 'bitbucket'),
|
||||
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
|
||||
'digitalocean': ('DigitalOcean', 'digital-ocean'),
|
||||
|
@ -1,10 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import HttpResponseNotFound, HttpResponseForbidden
|
||||
from django.http import HttpResponse
|
||||
from django.template import loader
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
@ -46,9 +42,3 @@ class NetBoxGraphQLView(GraphQLView):
|
||||
return HttpResponseForbidden("No credentials provided.")
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def render_graphql_ide(self, request):
|
||||
template = loader.get_template("graphiql.html")
|
||||
context = {"SUBSCRIPTION_ENABLED": json.dumps(self.subscriptions_enabled)}
|
||||
|
||||
return HttpResponse(template.render(context, request))
|
||||
|
@ -68,6 +68,8 @@ class JobRunner(ABC):
|
||||
finally:
|
||||
if job.interval:
|
||||
new_scheduled_time = (job.scheduled or job.started) + timedelta(minutes=job.interval)
|
||||
if job.object and getattr(job.object, "python_class", None):
|
||||
kwargs["job_timeout"] = job.object.python_class.job_timeout
|
||||
cls.enqueue(
|
||||
instance=job.object,
|
||||
user=job.user,
|
||||
|
@ -78,6 +78,7 @@ class PluginConfig(AppConfig):
|
||||
menu_items = None
|
||||
template_extensions = None
|
||||
user_preferences = None
|
||||
events_pipeline = []
|
||||
|
||||
def _load_resource(self, name):
|
||||
# Import from the configured path, if defined.
|
||||
|
@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import warnings
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.registry import registry
|
||||
@ -37,7 +38,12 @@ def register_template_extensions(class_list):
|
||||
# Registration for multiple models
|
||||
models = template_extension.models
|
||||
elif template_extension.model:
|
||||
# Registration for a single model
|
||||
# Registration for a single model (deprecated)
|
||||
warnings.warn(
|
||||
"PluginTemplateExtension.model is deprecated and will be removed in a future release. Use "
|
||||
"'models' instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
models = [template_extension.model]
|
||||
else:
|
||||
# Global registration (no specific models)
|
||||
|
@ -21,7 +21,7 @@ class PluginTemplateExtension:
|
||||
* config - Plugin-specific configuration parameters
|
||||
"""
|
||||
models = None
|
||||
model = None
|
||||
model = None # Deprecated; use `models` instead
|
||||
|
||||
def __init__(self, context):
|
||||
self.context = context
|
||||
|
@ -110,9 +110,9 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
|
||||
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
|
||||
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
|
||||
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
|
||||
'extras.events.process_event_queue',
|
||||
))
|
||||
])
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
@ -787,6 +787,10 @@ STRAWBERRY_DJANGO = {
|
||||
|
||||
PLUGIN_CATALOG_URL = 'https://api.netbox.oss.netboxlabs.com/v1/plugins'
|
||||
|
||||
EVENTS_PIPELINE = list(EVENTS_PIPELINE)
|
||||
if 'extras.events.process_event_queue' not in EVENTS_PIPELINE:
|
||||
EVENTS_PIPELINE.insert(0, 'extras.events.process_event_queue')
|
||||
|
||||
# Register any configured plugins
|
||||
for plugin_name in PLUGINS:
|
||||
try:
|
||||
@ -857,6 +861,13 @@ for plugin_name in PLUGINS:
|
||||
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
|
||||
})
|
||||
|
||||
events_pipeline = plugin_config.events_pipeline
|
||||
if events_pipeline:
|
||||
if type(events_pipeline) in (list, tuple):
|
||||
EVENTS_PIPELINE.extend(events_pipeline)
|
||||
else:
|
||||
raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple")
|
||||
|
||||
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
|
||||
try:
|
||||
from .local_settings import *
|
||||
|
@ -17,6 +17,9 @@ class DummyPluginConfig(PluginConfig):
|
||||
'testing-medium',
|
||||
'testing-high'
|
||||
]
|
||||
events_pipeline = [
|
||||
'netbox.tests.dummy_plugin.events.process_events_queue'
|
||||
]
|
||||
|
||||
|
||||
config = DummyPluginConfig
|
||||
|
2
netbox/netbox/tests/dummy_plugin/events.py
Normal file
2
netbox/netbox/tests/dummy_plugin/events.py
Normal file
@ -0,0 +1,2 @@
|
||||
def process_events_queue(events):
|
||||
pass
|
@ -4,12 +4,10 @@ from django.conf import settings
|
||||
from django.test import Client
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.models import Site
|
||||
from ipam.models import Prefix
|
||||
from dcim.models import Rack, Site
|
||||
from users.models import Group, ObjectPermission, Token, User
|
||||
from utilities.testing import TestCase
|
||||
from utilities.testing.api import APITestCase
|
||||
@ -410,18 +408,18 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
Site.objects.bulk_create(cls.sites)
|
||||
|
||||
cls.prefixes = (
|
||||
Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]),
|
||||
Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]),
|
||||
Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]),
|
||||
Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]),
|
||||
Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]),
|
||||
Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]),
|
||||
Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]),
|
||||
Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]),
|
||||
Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]),
|
||||
cls.racks = (
|
||||
Rack(name='Rack 1', site=cls.sites[0]),
|
||||
Rack(name='Rack 2', site=cls.sites[0]),
|
||||
Rack(name='Rack 3', site=cls.sites[0]),
|
||||
Rack(name='Rack 4', site=cls.sites[1]),
|
||||
Rack(name='Rack 5', site=cls.sites[1]),
|
||||
Rack(name='Rack 6', site=cls.sites[1]),
|
||||
Rack(name='Rack 7', site=cls.sites[2]),
|
||||
Rack(name='Rack 8', site=cls.sites[2]),
|
||||
Rack(name='Rack 9', site=cls.sites[2]),
|
||||
)
|
||||
Prefix.objects.bulk_create(cls.prefixes)
|
||||
Rack.objects.bulk_create(cls.racks)
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
@ -435,8 +433,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
def test_get_object(self):
|
||||
|
||||
# Attempt to retrieve object without permission
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ -448,23 +445,21 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
|
||||
|
||||
# Retrieve permitted object
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Attempt to retrieve non-permitted object
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
||||
def test_list_objects(self):
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
|
||||
# Attempt to list objects without permission
|
||||
response = self.client.get(url, **self.header)
|
||||
@ -478,7 +473,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
|
||||
|
||||
# Retrieve all objects. Only permitted objects should be returned.
|
||||
response = self.client.get(url, **self.header)
|
||||
@ -487,12 +482,12 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
||||
def test_create_object(self):
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
data = {
|
||||
'prefix': '10.0.9.0/24',
|
||||
'name': 'Rack 10',
|
||||
'site': self.sites[1].pk,
|
||||
}
|
||||
initial_count = Prefix.objects.count()
|
||||
initial_count = Rack.objects.count()
|
||||
|
||||
# Attempt to create an object without permission
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
@ -506,26 +501,25 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
|
||||
|
||||
# Attempt to create a non-permitted object
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(Prefix.objects.count(), initial_count)
|
||||
self.assertEqual(Rack.objects.count(), initial_count)
|
||||
|
||||
# Create a permitted object
|
||||
data['site'] = self.sites[0].pk
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Prefix.objects.count(), initial_count + 1)
|
||||
self.assertEqual(Rack.objects.count(), initial_count + 1)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
||||
def test_edit_object(self):
|
||||
|
||||
# Attempt to edit an object without permission
|
||||
data = {'site': self.sites[0].pk}
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ -537,26 +531,23 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
|
||||
|
||||
# Attempt to edit a non-permitted object
|
||||
data = {'site': self.sites[0].pk}
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Edit a permitted object
|
||||
data['status'] = 'reserved'
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Attempt to modify a permitted object to a non-permitted object
|
||||
data['site'] = self.sites[1].pk
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ -564,8 +555,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
def test_delete_object(self):
|
||||
|
||||
# Attempt to delete an object without permission
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@ -577,16 +567,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
|
||||
)
|
||||
obj_perm.save()
|
||||
obj_perm.users.add(self.user)
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix))
|
||||
obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack))
|
||||
|
||||
# Attempt to delete a non-permitted object
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[3].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Delete a permitted object
|
||||
url = reverse('ipam-api:prefix-detail',
|
||||
kwargs={'pk': self.prefixes[0].pk})
|
||||
url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk})
|
||||
response = self.client.delete(url, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
@ -203,3 +203,10 @@ class PluginTest(TestCase):
|
||||
self.assertEqual(get_plugin_config(plugin, 'foo'), 123)
|
||||
self.assertEqual(get_plugin_config(plugin, 'bar'), None)
|
||||
self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456)
|
||||
|
||||
|
||||
def test_events_pipeline(self):
|
||||
"""
|
||||
Check that events pipeline is registered.
|
||||
"""
|
||||
self.assertIn('netbox.tests.dummy_plugin.events.process_events_queue', settings.EVENTS_PIPELINE)
|
||||
|
@ -3,7 +3,7 @@ import re
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.fields import GenericRel
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
@ -576,7 +576,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
for name, model_field in model_fields.items():
|
||||
# Handle nullification
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
if type(model_field) is GenericForeignKey:
|
||||
setattr(obj, name, None)
|
||||
else:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
# Normal fields
|
||||
elif name in form.changed_data:
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
@ -688,7 +691,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
else:
|
||||
form = self.form(initial=initial_data)
|
||||
form = self.form(request.POST, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
# Retrieve objects being edited
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -29,8 +29,8 @@
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "10.3.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.1.0",
|
||||
"sass": "1.79.3",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.79.5",
|
||||
"tom-select": "2.3.1",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
42
netbox/project-static/src/buttons/floatBulk.ts
Normal file
42
netbox/project-static/src/buttons/floatBulk.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Conditionally add and remove a class that will float the button group
|
||||
* based on whether or not items in the list are checked
|
||||
*/
|
||||
function toggleFloat(): void {
|
||||
const checkedCheckboxes = document.querySelector<HTMLInputElement>(
|
||||
'input[type="checkbox"][name="pk"]:checked',
|
||||
);
|
||||
const buttonGroup = document.querySelector<HTMLDivElement>(
|
||||
'div.form.form-horizontal div.btn-list',
|
||||
);
|
||||
if (!buttonGroup) {
|
||||
return;
|
||||
}
|
||||
const isFloating = buttonGroup.classList.contains('btn-float-group-left');
|
||||
if (checkedCheckboxes !== null && !isFloating) {
|
||||
buttonGroup.classList.add('btn-float-group-left');
|
||||
} else if (checkedCheckboxes === null && isFloating) {
|
||||
buttonGroup.classList.remove('btn-float-group-left');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize floating bulk buttons.
|
||||
*/
|
||||
export function initFloatBulk(): void {
|
||||
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
|
||||
element.addEventListener('change', () => {
|
||||
toggleFloat();
|
||||
});
|
||||
}
|
||||
// Handle the select-all checkbox
|
||||
for (const element of getElements<HTMLInputElement>(
|
||||
'table tr th > input[type="checkbox"].toggle',
|
||||
)) {
|
||||
element.addEventListener('change', () => {
|
||||
toggleFloat();
|
||||
});
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
|
||||
import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initFloatBulk } from './floatBulk';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
import { initMarkdownPreviews } from './markdownPreview';
|
||||
import { initSecretToggle } from './secretToggle';
|
||||
@ -14,6 +15,7 @@ export function initButtons(): void {
|
||||
initReslug,
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initFloatBulk,
|
||||
initMoveButtons,
|
||||
initMarkdownPreviews,
|
||||
initSecretToggle,
|
||||
|
@ -28,16 +28,19 @@
|
||||
|
||||
}
|
||||
|
||||
// Remove the bottom margin of <p> elements inside a table cell
|
||||
td > .rendered-markdown {
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
|
||||
// Remove the bottom margin of the last <p> elements in markdown
|
||||
.rendered-markdown {
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fix layout of rendered markdown inside a table cell
|
||||
td > .rendered-markdown {
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
// Markdown preview
|
||||
.markdown-widget {
|
||||
.preview {
|
||||
|
@ -33,3 +33,33 @@ span.color-label {
|
||||
.netbox-edition {
|
||||
letter-spacing: .15rem;
|
||||
}
|
||||
|
||||
// A floating div for form buttons
|
||||
.btn-float-group {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.btn-float-group-left {
|
||||
@extend .btn-float-group;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.btn-float-group-right {
|
||||
@extend .btn-float-group;
|
||||
float: right;
|
||||
}
|
||||
|
||||
// Override a transparent background
|
||||
.btn-float {
|
||||
--tblr-btn-bg: var(--#{$prefix}bg-surface-tertiary) !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.sso-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
@ -131,6 +131,11 @@ body[data-bs-theme=dark] {
|
||||
.toast {
|
||||
color: var(--#{$prefix}body-color);
|
||||
}
|
||||
.table-primary {
|
||||
--tblr-table-bg: rgba(var(--tblr-secondary-rgb), 0.48);
|
||||
--tblr-table-hover-bg: inherit;
|
||||
--tblr-table-hover-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not apply padding to <code> elements inside a <pre>
|
||||
|
@ -365,6 +365,89 @@
|
||||
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58"
|
||||
integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
|
||||
|
||||
"@parcel/watcher-android-arm64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz#c2c19a3c442313ff007d2d7a9c2c1dd3e1c9ca84"
|
||||
integrity sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==
|
||||
|
||||
"@parcel/watcher-darwin-arm64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz#c817c7a3b4f3a79c1535bfe54a1c2818d9ffdc34"
|
||||
integrity sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==
|
||||
|
||||
"@parcel/watcher-darwin-x64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz#1a3f69d9323eae4f1c61a5f480a59c478d2cb020"
|
||||
integrity sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==
|
||||
|
||||
"@parcel/watcher-freebsd-x64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz#0d67fef1609f90ba6a8a662bc76a55fc93706fc8"
|
||||
integrity sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz#ce5b340da5829b8e546bd00f752ae5292e1c702d"
|
||||
integrity sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz#6d7c00dde6d40608f9554e73998db11b2b1ff7c7"
|
||||
integrity sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz#bd39bc71015f08a4a31a47cd89c236b9d6a7f635"
|
||||
integrity sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz#0ce29966b082fb6cdd3de44f2f74057eef2c9e39"
|
||||
integrity sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==
|
||||
|
||||
"@parcel/watcher-linux-x64-musl@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz#d2ebbf60e407170bb647cd6e447f4f2bab19ad16"
|
||||
integrity sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==
|
||||
|
||||
"@parcel/watcher-win32-arm64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz#eb4deef37e80f0b5e2f215dd6d7a6d40a85f8adc"
|
||||
integrity sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==
|
||||
|
||||
"@parcel/watcher-win32-ia32@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz#94fbd4b497be39fd5c8c71ba05436927842c9df7"
|
||||
integrity sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==
|
||||
|
||||
"@parcel/watcher-win32-x64@2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz#4bf920912f67cae5f2d264f58df81abfea68dadf"
|
||||
integrity sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==
|
||||
|
||||
"@parcel/watcher@^2.4.1":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.4.1.tgz#a50275151a1bb110879c6123589dba90c19f1bf8"
|
||||
integrity sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==
|
||||
dependencies:
|
||||
detect-libc "^1.0.3"
|
||||
is-glob "^4.0.3"
|
||||
micromatch "^4.0.5"
|
||||
node-addon-api "^7.0.0"
|
||||
optionalDependencies:
|
||||
"@parcel/watcher-android-arm64" "2.4.1"
|
||||
"@parcel/watcher-darwin-arm64" "2.4.1"
|
||||
"@parcel/watcher-darwin-x64" "2.4.1"
|
||||
"@parcel/watcher-freebsd-x64" "2.4.1"
|
||||
"@parcel/watcher-linux-arm-glibc" "2.4.1"
|
||||
"@parcel/watcher-linux-arm64-glibc" "2.4.1"
|
||||
"@parcel/watcher-linux-arm64-musl" "2.4.1"
|
||||
"@parcel/watcher-linux-x64-glibc" "2.4.1"
|
||||
"@parcel/watcher-linux-x64-musl" "2.4.1"
|
||||
"@parcel/watcher-win32-arm64" "2.4.1"
|
||||
"@parcel/watcher-win32-ia32" "2.4.1"
|
||||
"@parcel/watcher-win32-x64" "2.4.1"
|
||||
|
||||
"@pkgr/core@^0.1.0":
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31"
|
||||
@ -1196,6 +1279,11 @@ delegate@^3.1.2:
|
||||
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
|
||||
integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
|
||||
|
||||
detect-libc@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
|
||||
integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
|
||||
|
||||
detect-node-es@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
|
||||
@ -2205,7 +2293,7 @@ meros@^1.1.4:
|
||||
resolved "https://registry.yarnpkg.com/meros/-/meros-1.3.0.tgz#c617d2092739d55286bf618129280f362e6242f2"
|
||||
integrity sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==
|
||||
|
||||
micromatch@^4.0.4:
|
||||
micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
@ -2247,6 +2335,11 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
node-addon-api@^7.0.0:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
|
||||
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
@ -2417,10 +2510,10 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
query-string@9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
|
||||
integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
|
||||
query-string@9.1.1:
|
||||
version "9.1.1"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.1.tgz#dbfebb4196aeb2919915f2b2b81b91b965cf03a0"
|
||||
integrity sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==
|
||||
dependencies:
|
||||
decode-uri-component "^0.4.1"
|
||||
filter-obj "^5.1.0"
|
||||
@ -2563,11 +2656,12 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.79.3:
|
||||
version "1.79.3"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.3.tgz#7811b000eb68195fe51dea89177e73e7ef7f546f"
|
||||
integrity sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==
|
||||
sass@1.79.5:
|
||||
version "1.79.5"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.5.tgz#646c627601cd5f84c64f7b1485b9292a313efae4"
|
||||
integrity sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==
|
||||
dependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
chokidar "^4.0.0"
|
||||
immutable "^4.0.0"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
|
@ -1,3 +1,3 @@
|
||||
version: "4.1.3"
|
||||
version: "4.1.4"
|
||||
edition: "Community"
|
||||
published: "2024-10-02"
|
||||
published: "2024-10-15"
|
||||
|
@ -2,6 +2,7 @@
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}{{ plugin.title_long }}{% endblock %}
|
||||
|
||||
@ -93,8 +94,8 @@
|
||||
<div class="col col-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Version History" %}</h2>
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endif %}
|
||||
{% if 'bulk_rename' in actions %}
|
||||
{% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning">
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-float">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
|
||||
</button>
|
||||
{% endwith %}
|
||||
|
@ -78,7 +78,7 @@
|
||||
{% if 'bulk_edit' in actions %}
|
||||
<div class="btn-group" role="group">
|
||||
{% bulk_edit_button model query_params=request.GET %}
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning">
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning btn-float">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -42,71 +42,71 @@ Context:
|
||||
{# Edit form #}
|
||||
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="edit-form-tab">
|
||||
<form action="" method="post" class="form form-horizontal mt-5">
|
||||
|
||||
{% csrf_token %}
|
||||
{% if request.POST.return_url %}
|
||||
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
|
||||
{% endif %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{% if form.fieldsets %}
|
||||
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for fieldset in form.fieldsets %}
|
||||
{% render_fieldset form fieldset %}
|
||||
<div id="form_fields" hx-disinherit="hx-select hx-swap">
|
||||
{% csrf_token %}
|
||||
{% if request.POST.return_url %}
|
||||
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
|
||||
{% endif %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{# Render tag add/remove fields #}
|
||||
{% if form.add_tags and form.remove_tags %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Tags" %}</h2>
|
||||
{% if form.fieldsets %}
|
||||
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for fieldset in form.fieldsets %}
|
||||
{% render_fieldset form fieldset %}
|
||||
{% endfor %}
|
||||
|
||||
{# Render tag add/remove fields #}
|
||||
{% if form.add_tags and form.remove_tags %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Tags" %}</h2>
|
||||
</div>
|
||||
{% render_field form.add_tags %}
|
||||
{% render_field form.remove_tags %}
|
||||
</div>
|
||||
{% render_field form.add_tags %}
|
||||
{% render_field form.remove_tags %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Render custom fields #}
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Render comments #}
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Comments" %}</h2>
|
||||
</div>
|
||||
{% render_field form.comments bulk_nullable=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Render all fields #}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name in form.nullable_fields %}
|
||||
{% render_field field bulk_nullable=True %}
|
||||
{% else %}
|
||||
{% render_field field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{# Render custom fields #}
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
||||
{# Render comments #}
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Comments" %}</h2>
|
||||
</div>
|
||||
{% render_field form.comments bulk_nullable=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Render all fields #}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name in form.nullable_fields %}
|
||||
{% render_field field bulk_nullable=True %}
|
||||
{% else %}
|
||||
{% render_field field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-float-group-right">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
||||
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -67,9 +67,9 @@ Context:
|
||||
{% endblock form %}
|
||||
</div>
|
||||
|
||||
<div class="text-end my-3">
|
||||
<div class="btn-float-group-right">
|
||||
{% block buttons %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
||||
{% if object.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">
|
||||
{% trans "Save" %}
|
||||
@ -79,7 +79,7 @@ Context:
|
||||
<button type="submit" name="_create" class="btn btn-primary">
|
||||
{% trans "Create" %}
|
||||
</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-outline-primary">
|
||||
<button type="submit" name="_addanother" class="btn btn-outline-primary btn-float">
|
||||
{% trans "Create & Add Another" %}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -121,7 +121,7 @@ Context:
|
||||
{# /Objects table #}
|
||||
|
||||
{# Form buttons #}
|
||||
<div class="btn-list d-print-none mt-2">
|
||||
<div class="btn-list d-print-none">
|
||||
{% block bulk_buttons %}
|
||||
<div class="bulk-action-buttons">
|
||||
{% if 'bulk_edit' in actions %}
|
||||
|
@ -37,13 +37,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer text-end d-print-none border-0">
|
||||
<button type="button" class="btn btn-outline-danger m-1" data-reset-select>
|
||||
<i class="mdi mdi-backspace"></i> {% trans "Reset" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary m-1">
|
||||
<i class="mdi mdi-magnify"></i> {% trans "Search" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-float-group-right me-1">
|
||||
<button type="button" class="btn btn-outline-danger btn-float" data-reset-select>
|
||||
<i class="mdi mdi-backspace"></i> {% trans "Reset" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="mdi mdi-magnify"></i> {% trans "Search" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -44,17 +44,13 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if object.site.region %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Region" %}</th>
|
||||
<td>
|
||||
{% nested_tree object.site.region %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">{% trans "Site" %}</th>
|
||||
<td>{{ object.site|linkify|placeholder }}</td>
|
||||
<th scope="row">{% trans "Scope" %}</th>
|
||||
{% if object.scope %}
|
||||
<td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
|
||||
{% else %}
|
||||
<td>{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "VLAN" %}</th>
|
||||
|
@ -11,8 +11,8 @@
|
||||
|
||||
{# NetBox logo #}
|
||||
<div class="text-center mb-4">
|
||||
<img src="{% static 'logo_netbox_dark_teal.svg' %}" alt="{% trans "NetBox Logo" %}" height="80" class="hide-theme-dark">
|
||||
<img src="{% static 'logo_netbox_bright_teal.svg' %}" alt="{% trans "NetBox Logo" %}" height="80" class="hide-theme-light">
|
||||
<img src="{% static 'logo_netbox_dark_teal.svg' %}" alt="{% trans "NetBox Logo" %}" height="80" class="hide-theme-dark logo">
|
||||
<img src="{% static 'logo_netbox_bright_teal.svg' %}" alt="{% trans "NetBox Logo" %}" height="80" class="hide-theme-light logo">
|
||||
<div class="netbox-edition fw-semibold">{{ settings.RELEASE.edition }}</div>
|
||||
</div>
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
<div class="col">
|
||||
<a href="{{ backend.url }}" class="btn w-100">
|
||||
{% if backend.icon_name %}<i class="mdi mdi-{{ backend.icon_name }}"></i>
|
||||
{% elif backend.icon_img %}<img src="{{ backend.icon_img }}" height="24" {% if backend.display_name %}class="me-2" {% endif %}/>{% endif %}
|
||||
{% elif backend.icon_img %}<img src="{{ backend.icon_img }}" height="24"{% if backend.display_name %}class="me-2 sso-icon" {% endif %}/>{% endif %}
|
||||
{{ backend.display_name }}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,58 +0,0 @@
|
||||
import warnings
|
||||
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from serializers_.nested import NestedContactGroupSerializer, NestedTenantGroupSerializer
|
||||
from tenancy.models import *
|
||||
|
||||
__all__ = [
|
||||
'NestedContactSerializer',
|
||||
'NestedContactAssignmentSerializer',
|
||||
'NestedContactGroupSerializer',
|
||||
'NestedContactRoleSerializer',
|
||||
'NestedTenantGroupSerializer',
|
||||
'NestedTenantSerializer',
|
||||
]
|
||||
|
||||
# TODO: Remove in v4.2
|
||||
warnings.warn(
|
||||
"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tenants
|
||||
#
|
||||
|
||||
class NestedTenantSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Contacts
|
||||
#
|
||||
|
||||
class NestedContactRoleSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ContactRole
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'slug']
|
||||
|
||||
|
||||
class NestedContactSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name']
|
||||
|
||||
|
||||
class NestedContactAssignmentSerializer(WritableNestedSerializer):
|
||||
contact = NestedContactSerializer()
|
||||
role = NestedContactRoleSerializer
|
||||
|
||||
class Meta:
|
||||
model = ContactAssignment
|
||||
fields = ['id', 'url', 'display', 'contact', 'role', 'priority']
|
@ -66,6 +66,7 @@ class TenantGroupType(OrganizationalObjectType):
|
||||
parent: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
|
||||
tenants: List[TenantType]
|
||||
children: List[Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
|
||||
#
|
||||
@ -99,6 +100,7 @@ class ContactGroupType(OrganizationalObjectType):
|
||||
parent: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
|
||||
contacts: List[ContactType]
|
||||
children: List[Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user