Merge branch 'feature' into 7336-vlan-translation-feature

This commit is contained in:
Brian Tiemann 2024-10-20 12:33:18 -04:00
commit 55a2034918
121 changed files with 9792 additions and 9823 deletions

View File

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

View File

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

View File

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

View File

@ -12,6 +12,9 @@
"left-to-right",
"right-to-left",
"side-to-rear",
"rear-to-side",
"bottom-to-top",
"top-to-bottom",
"passive",
"mixed"
]

View 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.
![NetBox Google login form](../../media/authentication/netbox_google_login.png)
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.
![NetBox Google login form](../../media/authentication/google_login_portal.png)
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.

View File

@ -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).
![App registration parameters](../../media/authentication/azure_ad_app_registration.png)

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,10 @@ img {
margin-right: auto;
}
.md-content img {
background-color: rgba(255, 255, 255, 0.64);
}
/* Tables */
table {
margin-bottom: 24px;

View File

@ -1,4 +1,5 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
![NetBox](netbox_logo_light.svg#only-light "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"}
![NetBox](netbox_logo_dark.svg#only-dark "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"}
# The Premier Network Source of Truth

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

24
docs/netbox_logo_dark.svg Normal file
View 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

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,9 @@ class DummyPluginConfig(PluginConfig):
'testing-medium',
'testing-high'
]
events_pipeline = [
'netbox.tests.dummy_plugin.events.process_events_queue'
]
config = DummyPluginConfig

View File

@ -0,0 +1,2 @@
def process_events_queue(events):
pass

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View 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();
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
version: "4.1.3"
version: "4.1.4"
edition: "Community"
published: "2024-10-02"
published: "2024-10-15"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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