Merge branch 'netbox-community:develop' into 17542-Contact_Assignment_to_vpn_tunnels

This commit is contained in:
Antoine Keranflec'h 2024-11-12 14:17:05 +01:00 committed by GitHub
commit 113c8d1d85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
218 changed files with 74039 additions and 83842 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.3 placeholder: v4.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -36,9 +36,8 @@ body:
options: options:
- I volunteer to perform this work (if approved) - I volunteer to perform this work (if approved)
- I'm a NetBox Labs customer - I'm a NetBox Labs customer
- This is a very minor change
- N/A - N/A
default: 3 default: 2
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -31,16 +31,15 @@ body:
options: options:
- I volunteer to perform this work (if approved) - I volunteer to perform this work (if approved)
- I'm a NetBox Labs customer - I'm a NetBox Labs customer
- This is preventing me from using NetBox
- N/A - N/A
default: 3 default: 2
validations: validations:
required: true required: true
- type: input - type: input
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.3 placeholder: v4.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -7,6 +7,9 @@ contact_links:
- name: ❓ Discussion - name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead." about: "If you're just looking for help, try starting a discussion instead."
- name: 👔 Professional Support
url: https://netboxlabs.com/netbox-enterprise/
about: "Professional support is available for NetBox Enterprise or Cloud."
- name: 🌎 Correct a Translation - name: 🌎 Correct a Translation
url: https://explore.transifex.com/netbox-community/netbox/ url: https://explore.transifex.com/netbox-community/netbox/
about: "Spot an incorrect translation? You can propose a fix on Transifex." about: "Spot an incorrect translation? You can propose a fix on Transifex."

View File

@ -73,7 +73,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install pycodestyle coverage tblib pip install ruff coverage tblib
- name: Build documentation - name: Build documentation
run: mkdocs build run: mkdocs build
@ -85,7 +85,7 @@ jobs:
run: python netbox/manage.py makemigrations --check run: python netbox/manage.py makemigrations --check
- name: Check PEP8 compliance - name: Check PEP8 compliance
run: pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/ run: ruff check netbox/
- name: Check UI ESLint, TypeScript, and Prettier Compliance - name: Check UI ESLint, TypeScript, and Prettier Compliance
run: yarn --cwd netbox/project-static validate run: yarn --cwd netbox/project-static validate

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

12
.tx/config Executable file
View File

@ -0,0 +1,12 @@
[main]
host = https://app.transifex.com
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
source_file = netbox/translations/en/LC_MESSAGES/django.po
type = PO
minimum_perc = 0
resource_name = django.po
replace_edited_strings = false
keep_translations = false

View File

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
<p><strong>The cornerstone of every automated network</strong></p> <p><strong>The cornerstone of every automated network</strong></p>
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a> <a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a> <a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>

View File

@ -116,6 +116,10 @@ PyYAML
# https://github.com/psf/requests/blob/main/HISTORY.md # https://github.com/psf/requests/blob/main/HISTORY.md
requests requests
# rq
# https://github.com/rq/rq/blob/master/CHANGES.md
rq
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
social-auth-core social-auth-core

View File

@ -12,6 +12,9 @@
"left-to-right", "left-to-right",
"right-to-left", "right-to-left",
"side-to-rear", "side-to-rear",
"rear-to-side",
"bottom-to-top",
"top-to-bottom",
"passive", "passive",
"mixed" "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. 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) ![App registration parameters](../../media/authentication/azure_ad_app_registration.png)

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. 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` ### `name`
This is the human-friendly names of your script. If omitted, the class name will be used. 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. 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 ### 3. Create a Python Virtual Environment
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 pycodestyle package:
```no-highlight
python -m pip install pycodestyle
```
...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md)
### 4. 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. 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. 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. 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 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 ### 6. Configure NetBox
Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters:

View File

@ -90,7 +90,20 @@ This will automatically update the schema file at `contrib/generated_schema.json
### Update & Compile Translations ### Update & Compile Translations
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this. Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
```no-highlight
tx pull
```
Then, compile these portable (`.po`) files for use in the application:
```no-highlight
./manage.py compilemessages
```
!!! tip
Consult the translation documentation for more detail on [updating translated strings](./translations.md#updating-translated-strings) if you've not set up the Transifex client already.
### Update Version and Changelog ### Update Version and Changelog

View File

@ -1,6 +1,6 @@
# Style Guide # Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [ruff](https://docs.astral.sh/ruff/) is used for linting (with certain [exceptions](#linter-exceptions)).
## Code ## Code
@ -20,32 +20,32 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
### PEP 8 Exceptions ### Linting
NetBox ignores certain PEP8 assertions. These are listed below. The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run:
#### Wildcard Imports ```
ruff check netbox/
```
#### Linter Exceptions
The following rules are ignored when linting.
##### [E501](https://docs.astral.sh/ruff/rules/line-too-long/): Line too long
NetBox does not enforce a hard restriction on line length, although a maximum length of 120 characters is strongly encouraged for Python code where possible. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations).
##### [F403](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star/): Undefined local with import star
Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (e.g. `constants.py`) * The library being import contains only constant declarations (e.g. `constants.py`)
* The library being imported explicitly defines `__all__` * The library being imported explicitly defines `__all__`
#### Maximum Line Length (E501) ##### [F405](https://docs.astral.sh/ruff/rules/undefined-local-with-import-star-usage/): Undefined local with import star usage
NetBox does not restrict lines to a maximum length of 79 characters. We use a maximum line length of 120 characters, however this is not enforced by CI. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations). The justification for ignoring this rule is the same as F403 above.
#### Line Breaks Following Binary Operators (W504)
Line breaks are permitted following binary operators.
### Enforcing Code Style
The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `pycodestyle` manually, run:
```
pycodestyle --ignore=W504,E501 netbox/
```
### Introducing New Dependencies ### Introducing New Dependencies
@ -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. * 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

@ -16,26 +16,31 @@ To update the English `.po` file from which all translations are derived, use th
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically. Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
!!! note
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml).
## Updating Translated Strings ## Updating Translated Strings
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md). Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request. Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request.
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right. To download translated strings automatically, you'll need to:
![Transifex manual sync](../media/development/transifex_sync.png) 1. Install the [Transifex CLI client](https://github.com/transifex/cli)
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files. Once you have the client set up, run the following command:
!!! tip ```no-highlight
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added. TX_TOKEN=$TOKEN tx pull
```
![Transifex pull request](../media/development/transifex_pull_request.png) This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command: Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
```nohighlight ```no-highlight
./manage.py compilemessages ./manage.py compilemessages
``` ```

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 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

@ -1,11 +1,44 @@
# NetBox v4.1 # NetBox v4.1
## v4.1.4 (FUTURE) ## v4.1.6 (2024-10-31)
### Bug Fixes
* [#17700](https://github.com/netbox-community/netbox/issues/17700) - Fix warning when no scripts are found within a script module
* [#17884](https://github.com/netbox-community/netbox/issues/17884) - Fix translation support for certain tab headings
* [#17885](https://github.com/netbox-community/netbox/issues/17885) - Fix regression preventing custom scripts from executing
## v4.1.5 (2024-10-28)
### Enhancements
* [#17789](https://github.com/netbox-community/netbox/issues/17789) - Provide a single "scope" field for bulk editing VLAN group scope assignments
### Bug Fixes
* [#17358](https://github.com/netbox-community/netbox/issues/17358) - Fix validation of overlapping IP ranges
* [#17374](https://github.com/netbox-community/netbox/issues/17374) - Fix styling of highlighted table rows in dark mode
* [#17460](https://github.com/netbox-community/netbox/issues/17460) - Ensure bulk action buttons are consistent for device type components
* [#17635](https://github.com/netbox-community/netbox/issues/17635) - Ensure AbortTransaction is caught when running a custom script with `commit=False`
* [#17685](https://github.com/netbox-community/netbox/issues/17685) - Ensure background jobs are validated before being scheduled
* [#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
* [#17774](https://github.com/netbox-community/netbox/issues/17774) - Fix SSO login support for Entra ID (formerly Azure AD)
* [#17802](https://github.com/netbox-community/netbox/issues/17802) - Fix background color for bulk rename buttons in list views
* [#17838](https://github.com/netbox-community/netbox/issues/17838) - Adjust `manage.py` to reference `python3` executable
---
## v4.1.4 (2024-10-15)
### Enhancements ### Enhancements
* [#11671](https://github.com/netbox-community/netbox/issues/11671) - Display device's rack position in cable traces * [#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 * [#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 * [#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 * [#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 * [#17655](https://github.com/netbox-community/netbox/issues/17655) - Limit the display of tagged VLANs within interface tables
@ -14,12 +47,16 @@
### Bug Fixes ### Bug Fixes
* [#16024](https://github.com/netbox-community/netbox/issues/16024) - Fix AND/OR filtering in GraphQL API for selection fields * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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: - Administration:
- Authentication: - Authentication:
- Overview: 'administration/authentication/overview.md' - Overview: 'administration/authentication/overview.md'
- Google: 'administration/authentication/google.md'
- Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md' - Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md'
- Okta: 'administration/authentication/okta.md' - Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'

View File

@ -18,7 +18,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( warnings.warn(
f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
DeprecationWarning DeprecationWarning
) )

View File

@ -7,7 +7,7 @@ class CircuitsConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import signals, search from . import signals, search # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *

View File

@ -1,7 +1,6 @@
import strawberry
import strawberry_django import strawberry_django
from circuits import filtersets, models
from circuits import filtersets, models
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
__all__ = ( __all__ = (

View File

@ -171,7 +171,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.csv_update_data = ( cls.csv_update_data = (
f"id,cid,description,status", "id,cid,description,status",
f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}", f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",

View File

@ -16,7 +16,7 @@ __all__ = (
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( warnings.warn(
f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
DeprecationWarning DeprecationWarning
) )

View File

@ -8,10 +8,8 @@ from drf_spectacular.plumbing import (
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
) )
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from rest_framework.relations import ManyRelatedField
from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.fields import ChoiceField
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
# see netbox.api.routers.NetBoxRouter # see netbox.api.routers.NetBoxRouter

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.choices import * from core.choices import *
from core.models import Job from core.models import Job
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField

View File

@ -16,9 +16,9 @@ class CoreConfig(AppConfig):
name = "core" name = "core"
def ready(self): def ready(self):
from core.api import schema # noqa from core.api import schema # noqa: F401
from netbox.models.features import register_models from netbox.models.features import register_models
from . import data_backends, events, search from . import data_backends, events, search # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -34,7 +34,7 @@ class LocalBackend(DataBackend):
@contextmanager @contextmanager
def fetch(self): def fetch(self):
logger.debug(f"Data source type is local; skipping fetch") logger.debug("Data source type is local; skipping fetch")
local_path = urlparse(self.url).path # Strip file:// scheme local_path = urlparse(self.url).path # Strip file:// scheme
yield local_path yield local_path

View File

@ -15,7 +15,7 @@ __all__ = (
class ChangelogMixin: class ChangelogMixin:
@strawberry_django.field @strawberry_django.field
def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: # noqa: F821
content_type = ContentType.objects.get_for_model(self) content_type = ContentType.objects.get_for_model(self)
object_changes = ObjectChange.objects.filter( object_changes = ObjectChange.objects.filter(
changed_object_type=content_type, changed_object_type=content_type,

View File

@ -26,7 +26,7 @@ class Command(BaseCommand):
if invalid_names := set(options['name']) - found_names: if invalid_names := set(options['name']) - found_names:
raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}") raise CommandError(f"Invalid data source names: {', '.join(invalid_names)}")
else: else:
raise CommandError(f"Must specify at least one data source, or set --all.") raise CommandError("Must specify at least one data source, or set --all.")
if len(options['name']) > 1: if len(options['name']) > 1:
self.stdout.write(f"Syncing {len(datasources)} data sources.") self.stdout.write(f"Syncing {len(datasources)} data sources.")
@ -43,4 +43,4 @@ class Command(BaseCommand):
raise e raise e
if len(options['name']) > 1: if len(options['name']) > 1:
self.stdout.write(f"Finished.") self.stdout.write("Finished.")

View File

@ -125,7 +125,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type # Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''): if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({ raise ValidationError({
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)" 'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
}) })
def to_objectchange(self, action): def to_objectchange(self, action):
@ -201,7 +201,7 @@ class DataSource(JobsMixin, PrimaryModel):
logger.debug(f"Updated {updated_count} files") logger.debug(f"Updated {updated_count} files")
# Bulk delete deleted files # Bulk delete deleted files
deleted_count, _ = DataFile.objects.filter(pk__in=deleted_file_ids).delete() deleted_count, __ = DataFile.objects.filter(pk__in=deleted_file_ids).delete()
logger.debug(f"Deleted {deleted_count} files") logger.debug(f"Deleted {deleted_count} files")
# Walk the local replication to find new files # Walk the local replication to find new files

View File

@ -118,9 +118,9 @@ class Job(models.Model):
# TODO: Employ dynamic registration # TODO: Employ dynamic registration
if self.object_type: if self.object_type:
if self.object_type.model == 'reportmodule': if self.object_type.model == 'reportmodule':
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk}) return reverse('extras:report_result', kwargs={'job_pk': self.pk})
elif self.object_type.model == 'scriptmodule': elif self.object_type.model == 'scriptmodule':
return reverse(f'extras:script_result', kwargs={'job_pk': self.pk}) return reverse('extras:script_result', kwargs={'job_pk': self.pk})
return reverse('core:job', args=[self.pk]) return reverse('core:job', args=[self.pk])
def get_status_color(self): def get_status_color(self):
@ -130,7 +130,7 @@ class Job(models.Model):
super().clean() super().clean()
# Validate the assigned object type # Validate the assigned object type
if self.object_type not in ObjectType.objects.with_feature('jobs'): if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'):
raise ValidationError( raise ValidationError(
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
) )
@ -223,7 +223,7 @@ class Job(models.Model):
rq_queue_name = get_queue_for_model(object_type.model if object_type else None) rq_queue_name = get_queue_for_model(object_type.model if object_type else None)
queue = django_rq.get_queue(rq_queue_name) queue = django_rq.get_queue(rq_queue_name)
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
job = Job.objects.create( job = Job(
object_type=object_type, object_type=object_type,
object_id=object_id, object_id=object_id,
name=name, name=name,
@ -233,6 +233,8 @@ class Job(models.Model):
user=user, user=user,
job_id=uuid.uuid4() job_id=uuid.uuid4()
) )
job.full_clean()
job.save()
# Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous # Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous
# (blocking) operation, and execution will pause until the job completes. # (blocking) operation, and execution will pause until the job completes.

View File

@ -308,6 +308,7 @@ class BackgroundTaskTestCase(TestCase):
worker = get_worker('default') worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default) job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job) worker.prepare_job_execution(job)
worker.prepare_execution(job)
self.assertEqual(job.get_status(), JobStatus.STARTED) self.assertEqual(job.get_status(), JobStatus.STARTED)

View File

@ -56,7 +56,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( warnings.warn(
f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
DeprecationWarning DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import Manufacturer from dcim.models import Manufacturer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import Platform from dcim.models import Platform
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.choices import * from dcim.choices import *
from dcim.models import PowerFeed, PowerPanel from dcim.models import PowerFeed, PowerPanel
from netbox.api.fields import ChoiceField, RelatedObjectCountField from netbox.api.fields import ChoiceField, RelatedObjectCountField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.models import DeviceRole, InventoryItemRole from dcim.models import DeviceRole, InventoryItemRole
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField

View File

@ -10,7 +10,7 @@ class DCIMConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from utilities.counters import connect_counters from utilities.counters import connect_counters
from . import signals, search from . import signals, search # noqa: F401
from .models import CableTermination, Device, DeviceType, VirtualChassis from .models import CableTermination, Device, DeviceType, VirtualChassis
# Register models # Register models

View File

@ -13,10 +13,11 @@ from tenancy.models import Tenant
from users.models import User from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from wireless.models import WirelessLAN, WirelessLANGroup from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices from wireless.choices import WirelessRoleChoices
from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = ( __all__ = (
'CableBulkEditForm', 'CableBulkEditForm',
@ -721,6 +722,14 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
) )
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
query_params={
'site_id': ['$site', 'null']
},
)
comments = CommentField() comments = CommentField()
model = Device model = Device
@ -729,9 +738,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('site', 'location', name=_('Location')), FieldSet('site', 'location', name=_('Location')),
FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')), FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')),
FieldSet('config_template', name=_('Configuration')), FieldSet('config_template', name=_('Configuration')),
FieldSet('cluster', name=_('Virtualization')),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'cluster', 'comments',
) )
@ -1404,18 +1414,25 @@ class InterfaceBulkEditForm(
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'), label=_('Parent'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False,
query_params={
'virtual_chassis_member_id': '$device',
}
) )
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
label=_('Bridge'), label=_('Bridge'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False,
query_params={
'virtual_chassis_member_id': '$device',
}
) )
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
query_params={ query_params={
'type': 'lag', 'type': 'lag',
'virtual_chassis_member_id': '$device',
}, },
label=_('LAG') label=_('LAG')
) )
@ -1472,6 +1489,7 @@ class InterfaceBulkEditForm(
required=False, required=False,
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
}, },
label=_('Untagged VLAN') label=_('Untagged VLAN')
) )
@ -1480,9 +1498,28 @@ class InterfaceBulkEditForm(
required=False, required=False,
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
}, },
label=_('Tagged VLANs') label=_('Tagged VLANs')
) )
add_tagged_vlans = DynamicModelMultipleChoiceField(
label=_('Add tagged VLANs'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
},
)
remove_tagged_vlans = DynamicModelMultipleChoiceField(
label=_('Remove tagged VLANs'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
}
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -1509,7 +1546,13 @@ class InterfaceBulkEditForm(
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), FieldSet('mode', 'vlan_group', 'untagged_vlan', name=_('802.1Q Switching')),
FieldSet(
TabbedGroups(
FieldSet('tagged_vlans', name=_('Assignment')),
FieldSet('add_tagged_vlans', 'remove_tagged_vlans', name=_('Add/Remove')),
),
),
FieldSet( FieldSet(
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
name=_('Wireless') name=_('Wireless')
@ -1523,19 +1566,7 @@ class InterfaceBulkEditForm(
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.device_id: if not self.device_id:
device = Device.objects.filter(pk=self.device_id).first()
# Restrict parent/bridge/LAG interface assignment by device
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk)
# Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
else:
# See #4523 # See #4523
if 'pk' in self.initial: if 'pk' in self.initial:
site = None site = None
@ -1559,6 +1590,13 @@ class InterfaceBulkEditForm(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
) )
self.fields['add_tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['remove_tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True
self.fields['bridge'].choices = () self.fields['bridge'].choices = ()

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.models import * from dcim.models import *
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelMultipleChoiceField
from .model_forms import CableForm from .model_forms import CableForm

View File

@ -261,8 +261,8 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
# TODO: Clean up the application of HTMXSelect attributes # TODO: Clean up the application of HTMXSelect attributes
attrs={ attrs={
'hx-get': '.', 'hx-get': '.',
'hx-include': f'#form_fields', 'hx-include': '#form_fields',
'hx-target': f'#form_fields', 'hx-target': '#form_fields',
} }
) )
) )

View File

@ -1,7 +1,6 @@
from typing import Annotated, List, Union from typing import Annotated, List, Union
import strawberry import strawberry
import strawberry_django
__all__ = ( __all__ = (
'CabledObjectMixin', 'CabledObjectMixin',
@ -11,18 +10,18 @@ __all__ = (
@strawberry.type @strawberry.type
class CabledObjectMixin: class CabledObjectMixin:
cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None # noqa: F821
link_peers: List[Annotated[Union[ link_peers: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
], strawberry.union("LinkPeerType")]] ], strawberry.union("LinkPeerType")]]
@ -30,14 +29,14 @@ class CabledObjectMixin:
class PathEndpointMixin: class PathEndpointMixin:
connected_endpoints: List[Annotated[Union[ connected_endpoints: List[Annotated[Union[
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
], strawberry.union("ConnectedEndpointType")]] ], strawberry.union("ConnectedEndpointType")]]

View File

@ -112,7 +112,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
@strawberry_django.type( @strawberry_django.type(
models.CableTermination, models.CableTermination,
exclude=('termination_type', 'termination_id'), exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'),
filters=CableTerminationFilter filters=CableTerminationFilter
) )
class CableTerminationType(NetBoxObjectType): class CableTerminationType(NetBoxObjectType):
@ -243,6 +243,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo
consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
frontports: List[Annotated["FrontPortType", 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')]] modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]

View File

@ -60,7 +60,7 @@ class Command(BaseCommand):
self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths'))) self.stdout.write((self.style.SUCCESS(f' Deleted {deleted_count} paths')))
# Reinitialize the model's PK sequence # Reinitialize the model's PK sequence
self.stdout.write(f'Resetting database sequence for CablePath model') self.stdout.write('Resetting database sequence for CablePath model')
sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath]) sequence_sql = connection.ops.sequence_reset_sql(no_style(), [CablePath])
with connection.cursor() as cursor: with connection.cursor() as cursor:
for sql in sequence_sql: for sql in sequence_sql:

View File

@ -164,7 +164,7 @@ class Cable(PrimaryModel):
if self.length is not None and not self.length_unit: if self.length is not None and not self.length_unit:
raise ValidationError(_("Must specify a unit when setting a cable length")) raise ValidationError(_("Must specify a unit when setting a cable length"))
if self._state.adding and (not self.a_terminations or not self.b_terminations): if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError(_("Must define A and B terminations when creating a new cable.")) raise ValidationError(_("Must define A and B terminations when creating a new cable."))
if self._terminations_modified: if self._terminations_modified:
@ -666,6 +666,14 @@ class CablePath(models.Model):
rear_port_id=remote_terminations[0].pk, rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop() 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: else:
# No position indicated: path has split, so we stop at the RearPorts # No position indicated: path has split, so we stop at the RearPorts
is_split = True is_split = True

View File

@ -160,7 +160,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def _get_module_tree(self, module): def _get_module_tree(self, module):
modules = [] modules = []
all_module_bays = module.device.modulebays.all().select_related('module')
while module: while module:
modules.append(module) modules.append(module)
if module.module_bay: if module.module_bay:

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import Sum
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -22,7 +22,6 @@ from utilities.tracking import TrackingModelMixin
from wireless.choices import * from wireless.choices import *
from wireless.utils import get_channel_attr from wireless.utils import get_channel_attr
__all__ = ( __all__ = (
'BaseInterface', 'BaseInterface',
'CabledObjectModel', 'CabledObjectModel',

View File

@ -983,6 +983,13 @@ class Device(
'vc_position': _("A device assigned to a virtual chassis must have its position defined.") '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): def _instantiate_components(self, queryset, bulk_create=True):
""" """
Instantiate components for the device from the specified component templates. Instantiate components for the device from the specified component templates.

View File

@ -1,6 +1,5 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim import models from dcim import models
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns

View File

@ -2135,12 +2135,12 @@ class ConnectedDeviceTest(APITestCase):
def test_get_connected_device(self): def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list') url = reverse('dcim-api:connected-device-list')
url_params = f'?peer_device=TestDevice1&peer_interface=eth0' url_params = '?peer_device=TestDevice1&peer_interface=eth0'
response = self.client.get(url + url_params, **self.header) response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], 'TestDevice2') self.assertEqual(response.data['name'], 'TestDevice2')
url_params = f'?peer_device=TestDevice1&peer_interface=eth1' url_params = '?peer_device=TestDevice1&peer_interface=eth1'
response = self.client.get(url + url_params, **self.header) response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)

View File

@ -2060,6 +2060,49 @@ class CablePathTestCase(TestCase):
# Test SVG generation # Test SVG generation
CableTraceSVG(interface1).render() 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): def test_301_create_path_via_existing_cable(self):
""" """
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

@ -4838,13 +4838,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_role': [role[0].slug, role[1].slug]} params = {'device_role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_role(self):
role = DeviceRole.objects.all()[:2]
params = {'role_id': [role[0].pk, role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]} params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@ -662,10 +662,8 @@ class ModuleBayTestCase(TestCase):
def test_module_bay_recursion(self): def test_module_bay_recursion(self):
module_bay_1 = ModuleBay.objects.get(name='Module Bay 1') module_bay_1 = ModuleBay.objects.get(name='Module Bay 1')
module_bay_2 = ModuleBay.objects.get(name='Module Bay 2')
module_bay_3 = ModuleBay.objects.get(name='Module Bay 3') module_bay_3 = ModuleBay.objects.get(name='Module Bay 3')
module_1 = Module.objects.get(module_bay=module_bay_1) module_1 = Module.objects.get(module_bay=module_bay_1)
module_2 = Module.objects.get(module_bay=module_bay_2)
module_3 = Module.objects.get(module_bay=module_bay_3) module_3 = Module.objects.get(module_bay=module_bay_3)
# Confirm error if ModuleBay recurses # Confirm error if ModuleBay recurses
@ -681,8 +679,6 @@ class ModuleBayTestCase(TestCase):
module_1.save() module_1.save()
def test_single_module_token(self): def test_single_module_token(self):
module_bays = ModuleBay.objects.all()
modules = Module.objects.all()
device_type = DeviceType.objects.first() device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first() device_role = DeviceRole.objects.first()
site = Site.objects.first() site = Site.objects.first()
@ -708,7 +704,7 @@ class ModuleBayTestCase(TestCase):
location=location, location=location,
rack=rack rack=rack
) )
cp = device.consoleports.first() device.consoleports.first()
def test_nested_module_token(self): def test_nested_module_token(self):
pass pass
@ -733,39 +729,41 @@ class CableTestCase(TestCase):
device2 = Device.objects.create( device2 = Device.objects.create(
device_type=devicetype, role=role, name='TestDevice2', site=site device_type=devicetype, role=role, name='TestDevice2', site=site
) )
interface1 = Interface.objects.create(device=device1, name='eth0') interfaces = (
interface2 = Interface.objects.create(device=device2, name='eth0') Interface(device=device1, name='eth0'),
interface3 = Interface.objects.create(device=device2, name='eth1') Interface(device=device2, name='eth0'),
Cable(a_terminations=[interface1], b_terminations=[interface2]).save() Interface(device=device2, name='eth1'),
)
Interface.objects.bulk_create(interfaces)
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]]).save()
PowerPort.objects.create(device=device2, name='psu1')
power_port1 = PowerPort.objects.create(device=device2, name='psu1') patch_panel = Device.objects.create(
patch_pannel = Device.objects.create(
device_type=devicetype, role=role, name='TestPatchPanel', site=site device_type=devicetype, role=role, name='TestPatchPanel', site=site
) )
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c') rear_ports = (
front_port1 = FrontPort.objects.create( RearPort(device=patch_panel, name='RP1', type='8p8c'),
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1 RearPort(device=patch_panel, name='RP2', type='8p8c', positions=2),
RearPort(device=patch_panel, name='RP3', type='8p8c', positions=3),
RearPort(device=patch_panel, name='RP4', type='8p8c', positions=3),
) )
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2) RearPort.objects.bulk_create(rear_ports)
front_port2 = FrontPort.objects.create( front_ports = (
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1 FrontPort(device=patch_panel, name='FP1', type='8p8c', rear_port=rear_ports[0], rear_port_position=1),
) FrontPort(device=patch_panel, name='FP2', type='8p8c', rear_port=rear_ports[1], rear_port_position=1),
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3) FrontPort(device=patch_panel, name='FP3', type='8p8c', rear_port=rear_ports[2], rear_port_position=1),
front_port3 = FrontPort.objects.create( FrontPort(device=patch_panel, name='FP4', type='8p8c', rear_port=rear_ports[3], rear_port_position=1),
device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
)
rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
front_port4 = FrontPort.objects.create(
device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
) )
FrontPort.objects.bulk_create(front_ports)
provider = Provider.objects.create(name='Provider 1', slug='provider-1') provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider) provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1') circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2') circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A') CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z') CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A') CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
def test_cable_creation(self): def test_cable_creation(self):
""" """

View File

@ -2571,7 +2571,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
f"device,name,type,vrf.pk,poe_mode,poe_type", "device,name,type,vrf.pk,poe_mode,poe_type",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",

View File

@ -1,5 +1,3 @@
import itertools
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction

View File

@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from jinja2.exceptions import TemplateError from jinja2.exceptions import TemplateError
@ -35,7 +35,7 @@ from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import * from .models import *
CABLE_TERMINATION_TYPES = { CABLE_TERMINATION_TYPES = {
@ -2616,6 +2616,16 @@ class InterfaceBulkEditView(generic.BulkEditView):
table = tables.InterfaceTable table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm form = forms.InterfaceBulkEditForm
def post_save_operations(self, form, obj):
super().post_save_operations(form, obj)
# Add/remove tagged VLANs
if obj.mode == InterfaceModeChoices.MODE_TAGGED:
if form.cleaned_data.get('add_tagged_vlans', None):
obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans'])
if form.cleaned_data.get('remove_tagged_vlans', None):
obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans'])
class InterfaceBulkRenameView(generic.BulkRenameView): class InterfaceBulkRenameView(generic.BulkRenameView):
queryset = Interface.objects.all() queryset = Interface.objects.all()

View File

@ -24,7 +24,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( warnings.warn(
f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
DeprecationWarning DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from netbox.api.serializers import ValidatedModelSerializer from netbox.api.serializers import ValidatedModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import CustomLink from extras.models import CustomLink
from netbox.api.fields import ContentTypeField from netbox.api.fields import ContentTypeField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer
from core.models import ObjectType from core.models import ObjectType
from extras.models import ExportTemplate from extras.models import ExportTemplate

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import SavedFilter from extras.models import SavedFilter
from netbox.api.fields import ContentTypeField from netbox.api.fields import ContentTypeField

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from extras.models import Tag from extras.models import Tag
from netbox.api.fields import ContentTypeField, RelatedObjectCountField from netbox.api.fields import ContentTypeField, RelatedObjectCountField

View File

@ -1,6 +1,5 @@
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.module_loading import import_string
from django_rq.queues import get_connection from django_rq.queues import get_connection
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status from rest_framework import status
@ -15,8 +14,8 @@ from rq import Worker
from core.models import ObjectType from core.models import ObjectType
from extras import filtersets from extras import filtersets
from extras.models import *
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
from extras.models import *
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata

View File

@ -6,7 +6,7 @@ class ExtrasConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import dashboard, lookups, search, signals from . import dashboard, lookups, search, signals # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -15,7 +15,6 @@ from django.utils.translation import gettext as _
from core.models import ObjectType from core.models import ObjectType
from extras.choices import BookmarkOrderingChoices from extras.choices import BookmarkOrderingChoices
from netbox.choices import ButtonColorChoices
from utilities.object_types import object_type_identifier, object_type_name from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.querydict import dict_to_querydict from utilities.querydict import dict_to_querydict

View File

@ -48,8 +48,7 @@ class ScriptJob(JobRunner):
except AbortTransaction: except AbortTransaction:
script.log_info(message=_("Database changes have been reverted automatically.")) script.log_info(message=_("Database changes have been reverted automatically."))
if script.failed: if script.failed:
logger.warning(f"Script failed") logger.warning("Script failed")
raise
except Exception as e: except Exception as e:
if type(e) is AbortScript: if type(e) is AbortScript:

View File

@ -1,4 +1,5 @@
from django.db.models import CharField, TextField, Lookup from django.db.models import CharField, Lookup
from .fields import CachedValueField from .fields import CachedValueField

View File

@ -95,7 +95,7 @@ class Command(BaseCommand):
self.stdout.write("[*] Checking for latest release") self.stdout.write("[*] Checking for latest release")
if settings.ISOLATED_DEPLOYMENT: if settings.ISOLATED_DEPLOYMENT:
if options['verbosity']: if options['verbosity']:
self.stdout.write(f"\tSkipping: ISOLATED_DEPLOYMENT is enabled") self.stdout.write("\tSkipping: ISOLATED_DEPLOYMENT is enabled")
elif settings.RELEASE_CHECK_URL: elif settings.RELEASE_CHECK_URL:
headers = { headers = {
'Accept': 'application/vnd.github.v3+json', 'Accept': 'application/vnd.github.v3+json',
@ -129,7 +129,7 @@ class Command(BaseCommand):
self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR) self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
else: else:
if options['verbosity']: if options['verbosity']:
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set") self.stdout.write("\tSkipping: RELEASE_CHECK_URL not set")
if options['verbosity']: if options['verbosity']:
self.stdout.write("Finished.", self.style.SUCCESS) self.stdout.write("Finished.", self.style.SUCCESS)

View File

@ -96,9 +96,9 @@ class Command(BaseCommand):
if i: if i:
self.stdout.write(f'{i} entries cached.') self.stdout.write(f'{i} entries cached.')
else: else:
self.stdout.write(f'No objects found.') self.stdout.write('No objects found.')
msg = f'Completed.' msg = 'Completed.'
if total_count := search_backend.size: if total_count := search_backend.size:
msg += f' Total entries: {total_count}' msg += f' Total entries: {total_count}'
self.stdout.write(msg, self.style.SUCCESS) self.stdout.write(msg, self.style.SUCCESS)

View File

@ -51,7 +51,7 @@ class Command(BaseCommand):
user = User.objects.filter(is_superuser=True).order_by('pk')[0] user = User.objects.filter(is_superuser=True).order_by('pk')[0]
# Setup logging to Stdout # Setup logging to Stdout
formatter = logging.Formatter(f'[%(asctime)s][%(levelname)s] - %(message)s') formatter = logging.Formatter('[%(asctime)s][%(levelname)s] - %(message)s')
stdouthandler = logging.StreamHandler(sys.stdout) stdouthandler = logging.StreamHandler(sys.stdout)
stdouthandler.setLevel(logging.DEBUG) stdouthandler.setLevel(logging.DEBUG)
stdouthandler.setFormatter(formatter) stdouthandler.setFormatter(formatter)

View File

@ -283,7 +283,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
""" """
for ct in content_types: for ct in content_types:
model = ct.model_class() model = ct.model_class()
instances = model.objects.exclude(**{f'custom_field_data__contains': self.name}) instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
for instance in instances: for instance in instances:
instance.custom_field_data[self.name] = self.default instance.custom_field_data[self.name] = self.default
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100) model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)

View File

@ -554,7 +554,7 @@ class BaseScript:
""" """
Run the report and save its results. Each test method will be executed in order. Run the report and save its results. Each test method will be executed in order.
""" """
self.logger.info(f"Running report") self.logger.info("Running report")
try: try:
for test_name in self.tests: for test_name in self.tests:

View File

@ -12,7 +12,6 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from netbox.events import *
from users.models import Group, User from users.models import Group, User
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -793,7 +792,6 @@ class ScriptTest(APITestCase):
super().setUp() super().setUp()
# Monkey-patch the Script model to return our TestScriptClass above # Monkey-patch the Script model to return our TestScriptClass above
from extras.api.views import ScriptViewSet
Script.python_class = self.python_class Script.python_class = self.python_class
def test_get_script(self): def test_get_script(self):

View File

@ -162,7 +162,7 @@ class CustomValidatorTest(TestCase):
Site(name='abcdef123', slug='abcdef123').clean() Site(name='abcdef123', slug='abcdef123').clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]}) @override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]})
def test_valid(self): def test_related_object(self):
region1 = Region(name='Foo', slug='foo') region1 = Region(name='Foo', slug='foo')
region1.save() region1.save()
region2 = Region(name='Bar', slug='bar') region2 = Region(name='Bar', slug='bar')

View File

@ -49,11 +49,11 @@ class ConfigContextTest(TestCase):
sitegroup = SiteGroup.objects.create(name='Site Group') sitegroup = SiteGroup.objects.create(name='Site Group')
site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup) site = Site.objects.create(name='Site 1', slug='site-1', region=region, group=sitegroup)
location = Location.objects.create(name='Location 1', slug='location-1', site=site) location = Location.objects.create(name='Location 1', slug='location-1', site=site)
platform = Platform.objects.create(name='Platform') Platform.objects.create(name='Platform')
tenantgroup = TenantGroup.objects.create(name='Tenant Group') tenantgroup = TenantGroup.objects.create(name='Tenant Group')
tenant = Tenant.objects.create(name='Tenant', group=tenantgroup) Tenant.objects.create(name='Tenant', group=tenantgroup)
tag1 = Tag.objects.create(name='Tag', slug='tag') Tag.objects.create(name='Tag', slug='tag')
tag2 = Tag.objects.create(name='Tag2', slug='tag2') Tag.objects.create(name='Tag2', slug='tag2')
Device.objects.create( Device.objects.create(
name='Device 1', name='Device 1',

View File

@ -417,7 +417,7 @@ class EventRulesTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
f'name,object_types,event_types,action_type,action_object', 'name,object_types,event_types,action_type,action_object',
f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1', f'Webhook 4,dcim.site,"{OBJECT_CREATED},{OBJECT_UPDATED}",webhook,Webhook 1',
) )

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. # Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1].lower() 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]) filename = '.'.join([instance.name, extension])
elif instance.name: elif instance.name:
filename = instance.name filename = instance.name

View File

@ -1,4 +1,3 @@
import inspect
import operator import operator
from django.core import validators from django.core import validators

View File

@ -6,8 +6,8 @@ from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
@ -20,7 +20,6 @@ from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.registry import registry
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
@ -1181,7 +1180,8 @@ class ScriptView(BaseScriptView):
data=form.cleaned_data, data=form.cleaned_data,
request=copy_safe_request(request), request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout, job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit') commit=form.cleaned_data.pop('_commit'),
name=script.name
) )
return redirect('extras:script_result', job_pk=job.pk) return redirect('extras:script_result', job_pk=job.pk)

View File

@ -30,7 +30,7 @@ __all__ = [
# TODO: Remove in v4.2 # TODO: Remove in v4.2
warnings.warn( warnings.warn(
f"Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.",
DeprecationWarning DeprecationWarning
) )

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from ipam.models import Role from ipam.models import Role
from netbox.api.fields import RelatedObjectCountField from netbox.api.fields import RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from dcim.api.serializers_.devices import DeviceSerializer from dcim.api.serializers_.devices import DeviceSerializer
from ipam.choices import * from ipam.choices import *
from ipam.models import IPAddress, Service, ServiceTemplate from ipam.models import IPAddress, Service, ServiceTemplate

View File

@ -1,5 +1,3 @@
from rest_framework import serializers
from ipam.models import RouteTarget, VRF from ipam.models import RouteTarget, VRF
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer

View File

@ -186,13 +186,13 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
""" """
Return the parent object. Return the parent object.
""" """
raise NotImplemented() raise NotImplementedError()
def get_available_objects(self, parent, limit=None): def get_available_objects(self, parent, limit=None):
""" """
Return all available objects for the parent. Return all available objects for the parent.
""" """
raise NotImplemented() raise NotImplementedError()
def get_extra_context(self, parent): def get_extra_context(self, parent):
""" """
@ -250,7 +250,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
# Determine if the requested number of objects is available # Determine if the requested number of objects is available
if not self.check_sufficient_available(serializer.validated_data, available_objects): if not self.check_sufficient_available(serializer.validated_data, available_objects):
return Response( return Response(
{"detail": f"Insufficient resources are available to satisfy the request"}, {"detail": "Insufficient resources are available to satisfy the request"},
status=status.HTTP_409_CONFLICT status=status.HTTP_409_CONFLICT
) )

View File

@ -7,7 +7,7 @@ class IPAMConfig(AppConfig):
def ready(self): def ready(self):
from netbox.models.features import register_models from netbox.models.features import register_models
from . import signals, search from . import signals, search # noqa: F401
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())

View File

@ -105,6 +105,8 @@ IPAddressField.register_lookup(lookups.NetIn)
IPAddressField.register_lookup(lookups.NetHostContained) IPAddressField.register_lookup(lookups.NetHostContained)
IPAddressField.register_lookup(lookups.NetFamily) IPAddressField.register_lookup(lookups.NetFamily)
IPAddressField.register_lookup(lookups.NetMaskLength) IPAddressField.register_lookup(lookups.NetMaskLength)
IPAddressField.register_lookup(lookups.Host)
IPAddressField.register_lookup(lookups.Inet)
class ASNField(models.BigIntegerField): class ASNField(models.BigIntegerField):

View File

@ -1,22 +1,23 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _ 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.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.models import * from ipam.models import *
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant 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 ( from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
NumericRangeArrayField, NumericRangeArrayField,
) )
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect
from virtualization.models import Cluster, ClusterGroup from utilities.templatetags.builtins.filters import bettertitle
__all__ = ( __all__ = (
'AggregateBulkEditForm', 'AggregateBulkEditForm',
@ -429,62 +430,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
scope_type = ContentTypeChoiceField( scope_type = ContentTypeChoiceField(
label=_('Scope type'),
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
)
scope_id = forms.IntegerField(
required=False, required=False,
widget=forms.HiddenInput() label=_('Scope type')
) )
region = DynamicModelChoiceField( scope = DynamicModelChoiceField(
label=_('Region'), label=_('Scope'),
queryset=Region.objects.all(), queryset=Site.objects.none(), # Initial queryset
required=False
)
sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False, required=False,
label=_('Site group') disabled=True,
) selector=True
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',
}
) )
vid_ranges = NumericRangeArrayField( vid_ranges = NumericRangeArrayField(
label=_('VLAN ID ranges'), label=_('VLAN ID ranges'),
@ -494,24 +450,23 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
model = VLANGroup model = VLANGroup
fieldsets = ( fieldsets = (
FieldSet('site', 'vid_ranges', 'description'), FieldSet('site', 'vid_ranges', 'description'),
FieldSet( FieldSet('scope_type', 'scope', name=_('Scope')),
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
),
) )
nullable_fields = ('description',) nullable_fields = ('description', 'scope')
def clean(self): def __init__(self, *args, **kwargs):
super().clean() super().__init__(*args, **kwargs)
# Assign scope based on scope_type if scope_type_id := get_field_value(self, 'scope_type'):
if self.cleaned_data.get('scope_type'): try:
scope_field = self.cleaned_data['scope_type'].model scope_type = ContentType.objects.get(pk=scope_type_id)
if scope_obj := self.cleaned_data.get(scope_field): model = scope_type.model_class()
self.cleaned_data['scope_id'] = scope_obj.pk self.fields['scope'].queryset = model.objects.all()
self.changed_data.append('scope_id') self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
else: self.fields['scope'].disabled = False
self.cleaned_data.pop('scope_type') self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
self.changed_data.remove('scope_type') except ObjectDoesNotExist:
pass
class VLANBulkEditForm(NetBoxModelBulkEditForm): class VLANBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -1,7 +1,6 @@
from typing import Annotated, List from typing import Annotated, List
import strawberry import strawberry
import strawberry_django
__all__ = ( __all__ = (
'IPAddressesMixin', 'IPAddressesMixin',
@ -11,9 +10,9 @@ __all__ = (
@strawberry.type @strawberry.type
class IPAddressesMixin: class IPAddressesMixin:
ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] # noqa: F821
@strawberry.type @strawberry.type
class VLANGroupsMixin: class VLANGroupsMixin:
vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] # noqa: F821

View File

@ -580,15 +580,15 @@ class IPRange(ContactsMixin, PrimaryModel):
}) })
# Check for overlapping ranges # Check for overlapping ranges
overlapping_range = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( overlapping_ranges = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter(
Q(start_address__gte=self.start_address, start_address__lte=self.end_address) | # Starts inside Q(start_address__host__inet__gte=self.start_address.ip, start_address__host__inet__lte=self.end_address.ip) | # Starts inside
Q(end_address__gte=self.start_address, end_address__lte=self.end_address) | # Ends inside Q(end_address__host__inet__gte=self.start_address.ip, end_address__host__inet__lte=self.end_address.ip) | # Ends inside
Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside Q(start_address__host__inet__lte=self.start_address.ip, end_address__host__inet__gte=self.end_address.ip) # Starts & ends outside
).first() )
if overlapping_range: if overlapping_ranges.exists():
raise ValidationError( raise ValidationError(
_("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format( _("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format(
overlapping_range=overlapping_range, overlapping_range=overlapping_ranges.first(),
vrf=self.vrf vrf=self.vrf
)) ))

View File

@ -700,8 +700,6 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
device1.primary_ip4 = ip_addresses[0] device1.primary_ip4 = ip_addresses[0]
device1.save() device1.save()
ip2 = ip_addresses[1]
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
self.add_permissions('ipam.change_ipaddress') self.add_permissions('ipam.change_ipaddress')

View File

@ -36,6 +36,35 @@ class TestAggregate(TestCase):
self.assertEqual(aggregate.get_utilization(), 100) self.assertEqual(aggregate.get_utilization(), 100)
class TestIPRange(TestCase):
def test_overlapping_range(self):
iprange_192_168 = IPRange.objects.create(start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22'))
iprange_192_168.clean()
iprange_3_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.3.1/24'), end_address=IPNetwork('1.2.3.99/24'))
iprange_3_1_99.clean()
iprange_3_100_199 = IPRange.objects.create(start_address=IPNetwork('1.2.3.100/24'), end_address=IPNetwork('1.2.3.199/24'))
iprange_3_100_199.clean()
iprange_3_200_255 = IPRange.objects.create(start_address=IPNetwork('1.2.3.200/24'), end_address=IPNetwork('1.2.3.255/24'))
iprange_3_200_255.clean()
iprange_4_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.4.1/24'), end_address=IPNetwork('1.2.4.99/24'))
iprange_4_1_99.clean()
iprange_4_200 = IPRange.objects.create(start_address=IPNetwork('1.2.4.200/24'), end_address=IPNetwork('1.2.4.255/24'))
iprange_4_200.clean()
# Overlapping range entirely within existing
with self.assertRaises(ValidationError):
iprange_3_123_124 = IPRange.objects.create(start_address=IPNetwork('1.2.3.123/26'), end_address=IPNetwork('1.2.3.124/26'))
iprange_3_123_124.clean()
# Overlapping range starting within existing
with self.assertRaises(ValidationError):
iprange_4_98_101 = IPRange.objects.create(start_address=IPNetwork('1.2.4.98/24'), end_address=IPNetwork('1.2.4.101/24'))
iprange_4_98_101.clean()
# Overlapping range ending within existing
with self.assertRaises(ValidationError):
iprange_4_198_201 = IPRange.objects.create(start_address=IPNetwork('1.2.4.198/24'), end_address=IPNetwork('1.2.4.201/24'))
iprange_4_198_201.clean()
class TestPrefix(TestCase): class TestPrefix(TestCase):
def test_get_duplicates(self): def test_get_duplicates(self):

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